This commit is contained in:
khodowany
2026-10-04 18:48:02 -05:00
parent 68df7292ee
commit 0e68f5364b
58 changed files with 1416 additions and 7567 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import argparse,concurrent.futures,contextlib,copy,json,multiprocessing,tempfile
from pathlib import Path
from generator import production,vocabulary
from tests.test_solo import song
from tests.solo_fixtures import song
def main():
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--output',type=Path,required=True);a=p.parse_args();a.output.mkdir(parents=True,exist_ok=True)
+21 -9
View File
@@ -138,7 +138,7 @@ def audition_bounds(*reviews):
return min(a for a,b in points),max(a+b for a,b in points)
def audition_pair(actual,expected,role,assets,output,cache):
def audition_pair(actual,expected,role,assets,output,cache,baseline=None):
from generator import model, playback, score
from generator.solo.export import excerpt,region
song,part=role.split('/')
@@ -153,14 +153,15 @@ def audition_pair(actual,expected,role,assets,output,cache):
duration=end-origin
timeline=playback.compile_timeline(backing)
back=render_events([(model.fraction(e['onset'])-origin,model.fraction(e['onset'])+model.fraction(e['duration'])-origin,model.fraction(e['pitch'])) for e in timeline['events']],duration,tempo,output,cache)
(output/'audio').mkdir(parents=True,exist_ok=True)
clips=[]
for label,r in (('generated',actual),('reference',expected)):
for label,r in (('generated',actual),('reference',expected),*((('baseline',baseline),) if baseline else ())):
audio=render_events([(F(n['start'])-origin,F(n['end'])-origin,F(n['pitch'])) for n in events(r)],duration,tempo,output,cache)
combined=array.array('f',(a+.55*b for a,b in zip(audio,back)))
for context,values in (('solo',audio),('fixed_backing',combined)):
peak=.65*max(map(abs,values),default=0)
if peak>=1:raise ValueError('Comparison would clip at the shared rendering gain')
name=role.replace('/','_')+'_'+label+'_'+context+'.wav'
name='audio/'+role.replace('/','_')+'_'+label+'_'+context+'.wav'
playback.write_pcm(output/name,(.65*v for v in values))
clips.append({'file':name,'version':label,'context':context,'peak':peak})
if context=='fixed_backing':
@@ -176,18 +177,24 @@ def audition_pair(actual,expected,role,assets,output,cache):
'listening_status':'Rendered and signal-checked, not subjectively auditioned.'}
def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None):
def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None,baseline_dir=None):
generated_dir,output=Path(generated_dir),Path(output)
manifest=json.loads((generated_dir/'manifest.solo').read_text())
output.mkdir(parents=True,exist_ok=True)
tone_cache={}
baseline_manifest=json.loads((Path(baseline_dir)/'manifest.solo').read_text()) if baseline_dir else None
result={'comparison_only':True,'feeds_generation':False,'solos':{}}
for role,path in sorted(reference_paths.items()):
item=manifest['solos'][role]
actual=read(generated_dir/item['file'],role,item['form_start_measure'])
expected=read(path,role,starts.get(role),pitch_convention)
review=compare(actual,expected)
if assets:review['audition']=audition_pair(actual,expected,role,assets,output,tone_cache)
baseline=None
if baseline_manifest:
old=baseline_manifest['solos'][role]
baseline=read(Path(baseline_dir)/old['file'],role,old['form_start_measure'])
review['baseline']=musical_summary(baseline)
if assets:review['audition']=audition_pair(actual,expected,role,assets,output,tone_cache,baseline)
review['production_trace']=item['trace']
review['production_phrases']=item.get('phrase_report',{})
# Preserve the real role cutoff; reference material beyond it is not trimmed.
@@ -208,15 +215,19 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
reference_path=Path(reference_paths[role])
rows += ['<h2>'+html.escape(role)+'</h2>',f'<p>Exact event match: <strong>{"yes" if r["exact_musical_match"] else "no"}</strong>. {r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.</p>',
'<p><a href="'+html.escape(os.path.relpath(actual_path,output))+'">Generated MusicXML</a> · <a href="'+html.escape(os.path.relpath(reference_path,output))+'">Untouched reference MusicXML</a></p>']
if baseline_manifest:
baseline_path=Path(baseline_dir)/baseline_manifest['solos'][role]['file']
rows.append('<p><a href="'+html.escape(os.path.relpath(baseline_path,output))+'">Previous generated MusicXML</a></p>')
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>')
funnel=gesture_coverage(r['production_phrases'].get('selection_trace',{}).get('joint_gestures',{}))
funnel=r['production_phrases'].get('selection_trace',{}).get('operation_coverage',{})
if funnel:
rows.append('<details><summary>Construction coverage (not similarity scoring)</summary><table><tr><th>Purpose</th><th>Proposed structures</th><th>Retained jobs</th><th>Valid routes</th><th>Retained units</th></tr>')
stages=('eligible_contexts','proposed','rhythm_valid','complete_routes','valid','finalists','placed','lookahead_valid','selected')
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[k])+'</td>' for k in ('proposed','retained_jobs','valid_routes','retained_units'))+'</tr>')
rows.append('<tr><td>'+html.escape(purpose)+'</td>'+''.join('<td>'+str(counts.get(k,0))+'</td>' for k in stages)+'</tr>')
rows.append('</table></details>')
for clip in r.get('audition',{}).get('clips',[]):
rows.append('<p>'+html.escape(clip['version']+' / '+clip['context'])+'<br><audio controls preload="none" src="'+html.escape(clip['file'])+'"></audio></p>')
@@ -236,10 +247,11 @@ def main():
parser.add_argument('--form-start',action='append',default=[],metavar='SONG/ROLE=MEASURE')
parser.add_argument('--reference-pitch',choices=('encoded','guitar-written'),default='encoded')
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.')
args=parser.parse_args()
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)
{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)
if __name__=='__main__':main()
-141
View File
@@ -1,141 +0,0 @@
{
"version": 1,
"provenance": "Original transposable transformations authored from the musical brief, independently of audition references. Group operations and evaluator policy independently authored from the repair brief; no target inspection or fitting.",
"license": "MIT",
"transformations": [
{
"id": "rhythmic_seed",
"preserve": [
"rhythm"
],
"purpose": "introduce"
},
{
"id": "contour_seed",
"preserve": [
"contour"
],
"purpose": "introduce"
},
{
"id": "opening_fragment",
"preserve": [
"rhythm",
"pitches"
],
"purpose": "introduce"
},
{
"id": "characteristic_interval",
"preserve": [
"exact_intervals"
],
"purpose": "introduce"
},
{
"id": "return",
"preserve": [
"rhythm",
"intervals"
],
"purpose": "recognition"
},
{
"id": "develop_rhythm",
"preserve": [
"rhythm"
],
"purpose": "new pitch continuation"
},
{
"id": "develop_contour",
"preserve": [
"intervals"
],
"purpose": "rhythmic development"
},
{
"id": "continue",
"preserve": [],
"purpose": "free complete connection conditioned on incoming sound and later destination"
},
{
"id": "answer",
"preserve": [
"contour"
],
"purpose": "related answer with new timing and destination"
},
{
"id": "extend",
"preserve": [
"contour"
],
"purpose": "reopen a provisional ending into a longer route"
},
{
"id": "fragment",
"preserve": [
"rhythm"
],
"purpose": "develop a selected fragment"
}
],
"ending_holds": [
"2",
"5/2",
"3",
"7/2",
"4"
],
"ending_releases": [
"0",
"1/2",
"1",
"2"
],
"operations": [
{
"id": "develop_contour",
"after_statement": true
},
{
"id": "develop_rhythm",
"after_statement": true
},
{
"id": "answer",
"after_statement": true
},
{
"id": "extend",
"after_statement": true,
"needs_recent": true
},
{
"id": "fragment",
"after_statement": true
},
{
"id": "continue",
"after_statement": true
},
{
"id": "return",
"after_statement": true,
"entry_only": true
}
],
"evaluation": {
"version": "joint-musical-1",
"weights": {
"rhythm": 3,
"destination": 2,
"harmony": 2,
"register": 1,
"repetition": 1,
"activity": 1,
"difficulty": 0.5
}
}
}
+12 -12
View File
@@ -5,32 +5,32 @@
"roles": {
"acoustic_solo": {
"seed": "acoustic",
"preferred_features": [
"rhythmic_seed",
"opening_fragment",
"characteristic_interval",
"contour_seed"
],
"register_center": 57,
"register_alternatives": [
-7,
0,
7
],
"preferred_actions": [
"arpeggio",
"answer",
"develop",
"neighbor"
]
},
"electric_solo": {
"seed": "electric",
"preferred_features": [
"characteristic_interval",
"contour_seed",
"rhythmic_seed",
"opening_fragment"
],
"register_center": 62,
"register_alternatives": [
-7,
0,
7
],
"preferred_actions": [
"scalar",
"arpeggio",
"develop",
"answer"
]
},
"acoustic_accompaniment": {
@@ -1,6 +1,6 @@
{
"version": 1,
"provenance": "Independently authored, transposable rhythmic gestures and chord-member traversal operators. No notes, rhythms, measurements, or fitted parameters were extracted from the optional reference scores. Joint gesture operators were motivated by analysis of the supplied validation solos, then authored as general harmonic/rhythmic transformations. No target phrase, position, pitch sequence or frequency is stored.",
"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.",
"license": "MIT",
"cells": [
{
@@ -157,114 +157,179 @@
"exit": "release"
}
],
"chord_paths": [
"groups": [
{
"id": "ascending_members",
"purpose": [
"entrance",
"continue",
"connect",
"arrival"
],
"direction": "up",
"member_steps": [
1
],
"prefix_attacks": [
2,
3,
4
],
"max_interval": 7,
"members": "basic",
"require_chord_entry": true
},
{
"id": "descending_members",
"purpose": [
"entrance",
"continue",
"connect",
"arrival"
],
"direction": "down",
"member_steps": [
1
],
"prefix_attacks": [
2,
3,
4
],
"max_interval": 7,
"members": "basic",
"require_chord_entry": true
},
{
"id": "member_turn",
"purpose": [
"continue",
"connect",
"answer"
],
"direction": "toward",
"member_steps": [
1,
-1
],
"prefix_attacks": [
3
],
"max_interval": 7,
"members": "basic",
"require_chord_entry": true
},
{
"id": "guide_connection",
"purpose": [
"continue",
"connect",
"answer",
"arrival"
],
"direction": "toward",
"member_steps": [
1
],
"prefix_attacks": [
2
],
"max_interval": 7,
"members": "defining",
"require_chord_entry": false
}
],
"gesture_rules": [
{
"id": "harmonic_arrival",
"kind": "harmonic_arrival",
"arrival_offsets": [
"-1/2",
"0",
"id": "even_motion",
"values": [
"1/2",
"1/2"
],
"holds": [
"3/2",
"5/2"
],
"release": "1/2",
"purpose": "Approach a harmonic change, settle, then release; earlier arrivals require an anticipation contract."
"purpose": [
"arpeggio",
"scalar",
"develop",
"answer"
]
},
{
"id": "sustained_color",
"kind": "sustained_color",
"purpose": "Choose an approved exposed color with its required continuation before realizing the route."
"id": "balanced",
"values": [
"1",
"1/2",
"1/2"
],
"purpose": [
"arpeggio",
"scalar",
"develop",
"answer",
"neighbor"
]
},
{
"id": "offbeat_turn",
"values": [
"1/2",
"1",
"1/2"
],
"purpose": [
"arpeggio",
"develop",
"answer",
"neighbor"
]
},
{
"id": "broad_then_move",
"values": [
"3/2",
"1/2"
],
"purpose": [
"arpeggio",
"develop",
"answer"
]
},
{
"id": "separated_answer",
"values": [
"1",
"r:1/2",
"1/2"
],
"purpose": [
"answer",
"neighbor"
]
},
{
"id": "triplet_motion",
"values": [
"1/3",
"1/3",
"1/3"
],
"purpose": [
"scalar",
"develop"
],
"difficulty": "triplet"
},
{
"id": "sixteenth_turn",
"values": [
"1/4",
"1/4",
"1/4",
"1/4"
],
"purpose": [
"scalar",
"neighbor"
],
"difficulty": "sixteenth"
},
{
"id": "single_step",
"values": [
"1/2"
],
"purpose": [
"arpeggio",
"scalar",
"develop",
"answer",
"neighbor"
]
},
{
"id": "quarter_step",
"values": [
"1"
],
"purpose": [
"arpeggio",
"scalar",
"develop",
"answer",
"neighbor"
]
}
],
"motion_subdivision": {
"minimum_value": "2",
"pulse": "1/2",
"max_attacks": 16,
"purpose": "Reopen one provisional long value into continuous mapped movement; committed arrivals and exact rhythmic identities are excluded."
"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
}
}
+14 -25
View File
@@ -1,31 +1,20 @@
# Current active path
# Active solo path after the rewrite
Starting revision: `f234dd6`. Compact baseline: `build/solo_reference_comparison/previous/`; source/reference hashes: `preserved_inputs.json`.
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.
Current authorization permits studying the four supplied solos to identify reusable musical relationships. It does not permit production reads, stored reference phrases, target-loss selection or song-specific recipes. References remain unchanged.
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.
## Verified findings
| Finding | Stage | Repair |
| Finding | Before this rewrite | Resolution |
|---|---|---|
| Shape-changing expressions used an already realized provisional route | Construction | Plan timing, destination, hold/release and contracts before route realization |
| An approach could not resolve into an anticipated target | Validity/construction | Exact shared target and harmonic boundary; require both contracts |
| New on-time arrivals could lose their pitch during route filling | Construction | Fix selected arrival pitches and protect their sounding tails |
| Enclosures lacked a mapped whole-tone upper neighbor | Vocabulary | Two additional target-relative orders; actual collection membership required |
| Minor sixth always demanded immediate neighbor resolution | Policy | Bounded stable tonic/predominant color |
| Uniform eighth movement inherited a long-equal-value penalty | Retrieval | Eighth movement remains available without that penalty |
| Internal provisional holds excluded flowing alternatives | Construction | Subdivide a suitable internal span; preserve committed ending tails |
| Reference labels in technical fingering containers were missed | Presentation | Preserve verbatim label and original container |
| Repeated approach/rhythm/fret checks dominated new work | Performance | Fully keyed bounded caches; caller provenance restored |
| 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 |
The older score overwrite, inert density term, absolute-onset rhythm proxy, string bans and generated-part dependencies were already repaired before this revision. They are not claimed as new work.
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.
## Production call graph
`generator → arrange.generate → production.generate_tree → generate_role → composer.compose → leads.thematic_options → themes.phrase_candidates → themes.unit_candidates → gestures.select / routes.candidates → checked_phrase / scoring.musical → independent winner → notation → assembly`.
Source-feature retrieval covers the original head. A realized home theme guides later development. Meter-specific rhythms and general grammar supply alternatives. Four selected timing templates can request joint gestures; the total route-job budget remains 16. Complete planned routes keep eight intermediate representatives. Ordinary routes retain sequence-catalog and chord-traversal support. Bounded join/timing/local-decoration revisions preserve contracts.
`selection_trace.operations` reports operation eligibility, proposals, validity and pruning. `joint_gestures` separately reports structural proposals, retained jobs, valid routes and retained units. Counts do not imply a selected use.
Canonical accompaniment remains source-only and independent. Explicit legacy paraphrase/cell APIs remain compatibility/test paths, without production fallback. Unused historical audition controllers were removed.
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.
+3 -2
View File
@@ -12,10 +12,10 @@ Staged generation:
-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`. Copy completed `generated_assets/solo_xml/` into `build/solo_reference_comparison/generated/` for the comparison.
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 --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
-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:
@@ -29,6 +29,7 @@ Tests:
```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
```
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.
+20 -22
View File
@@ -1,26 +1,24 @@
# Data and cleanup inventory
# Musical data and code migration
Rules were authored as transposable theory relationships under the repository license. Inspection of the four solos motivated the repair; provenance states that openly. No reference phrases, bar recipes, fingerprints, aggregate distributions or fitted weights were stored.
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.
| Resource | Action | Live consumer |
| Resource | Action | Live consumer / reason |
|---|---|---|
| `data/songs/*.xml` | Keep both unchanged | Original source analysis |
| `data/scales/` | Keep 21 patterns | Mapper / ordinary permission |
| `data/sequences/` | Keep 2,954 paths | Complete relative routes |
| `data/rhythms/` | Keep 3,538 meter-specific snippets | Rhythm retrieval |
| `composition/rhythms.json` | Keep nine melodic cells/six comping figures | Themes and canonical accompaniment |
| `composition/melodic_vocabulary.json` | Keep eleven cells/four traversals; extend two traversal prefix lengths | Chord-route construction |
| Same | Add two joint gesture rules and internal subdivision policy | `gestures.proposals`, `themes.patterns` |
| `composition/lead_ins.json` | Seven formulas become nine; keep two blues rules | Construction and typed validation |
| `composition/harmony.json` | Keep eleven colors; minor sixth gains bounded stable use | Harmonic validator and construction |
| `composition/grammar.json`, `roles.json` | Keep | Evaluation, development and independent role preferences |
| `data/expected/` | Keep validation-only | Compatibility checks |
| Four supplied `build/*-solo-1/2.xml` | Keep untouched | Explicit optional review |
| `audition/theory_review.py` | Add | Full event/relationship analysis against original source |
| `audition/reference_review.py` | Extend note label evidence | Preserve raw label containers |
| `audition/controls/original_lead_theme.json` | Remove | Superseded unused audition control |
| `audition/production_review.py`, `release_evidence.py` | Remove | Historical multi-experiment exporters |
| `generator/solo/audition.py`, `lead_audition.py` | Remove | Historical unused audition entry points |
| Old generated experiment trees/logs | Remove | 1.82 GiB initially; exact paths/bytes in cleanup manifest |
| `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 |
Schema 1 gains required gesture fields checked by the loader. Composition version: `joint-arrivals-2`. Runtime score/notation schemas are unchanged. No accompaniment vocabulary or role policy changed. Independent positive/negative tests do not read targets. Optional transfer fixtures remain outside production data.
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.
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.
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.
+9 -7
View File
@@ -1,13 +1,15 @@
# Independent inputs
# Reproducibility boundary
The preserved contract is `original source + relevant data + own role/settings + algorithm → part`. Six roles use a bounded spawn-process pool, normally two workers. No pairwise selection, generated-solo-responsive comping or other-role grip reuse exists in the active path.
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.
The scoped `input_audit.composition` guard checks actual reads. References, previous output and other-role concrete caches are prohibited composition inputs. Source analysis is supplied explicitly; assembly/rendering legitimately consume finished roles afterward.
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.
New per-Harmony caches include exact timing/pitches/roles, continuation, profile, fixed endpoints, obligations and ending constraints. Only caller IDs/provenance are normalized for reuse, then restored. Focused tests compare uncached/cached proposals and complete routes. Pure rhythm validity and fret-position feasibility use bounded memoization.
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 optional dependency command tests all six roles alone, reversed, cold/warm, in two processes, with invalid targets present/deleted, and after an electric-only setting change. Actual reads and document hashes accompany equality assertions. This is a one-bar independent fixture with real catalogs, not a whole-song quality test.
Verification artifacts under `build/solo_reference_comparison/` record:
Current evidence lives in `build/solo_reference_comparison/dependency/`. The implementation report lists actual completed checks. Full output traces live in `generated_assets/analysis/*.roles`.
- 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.
References informed general rule reasoning under the current authorization, but production never reads them. A deletion test cannot detect an embedded phrase bank; provenance and the small explicit resource diff supply that separate check.
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.
+13 -16
View File
@@ -1,23 +1,20 @@
# Evaluation and validity
# One musical evaluator
The active final selector remains `scoring.musical` / `MusicalEvaluation`, version `joint-musical-1`. Each phrase score is its evaluation total; the complete solo uses duration-weighted completed phrase evaluations. This revision changes construction coverage, not the final weights.
`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.
| Component | Weight |
|---|---:|
| Rhythmic grouping/interruptions | 3 |
| Destination preparation | 2 |
| Harmonic exposure | 2 |
| Soft register preference | 1 |
| Contextual repetition | 1 |
| Activity | 1 |
| Approximate playing difficulty | 0.5 |
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.
Reports expose raw values, denominators, weights, contributions, total and validity. Identity, expression completion, physical domain and protected holds are hard contracts, not duplicate resemblance costs. Early retrieval estimates remain separate.
| 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 |
Planned arrival spans have arrival activity semantics; they need no attack on every held beat. Silence cannot exploit a missing register value. Ties merge before harmonic duration/attack evaluation; repeated attacks cannot fulfill a hold.
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.
Joint arrival/color/approach candidates exist before route search. An approach into future harmony requires the exact shared anticipation target. Mapped upper neighbors must actually belong to the collection. The stable minor-sixth policy is bounded by harmony/function/duration and does not legalize unrelated notes.
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.
Selected ordinary arrival pitches and tails are also protected through refinement; ordinary computational endpoints remain provisional. Disabling expressions removes joint expression proposals and local ornaments and may choose another valid complete phrase. It does not mean deleting exceptional notes from a winner.
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.
Reference recognition, proposed candidate, validity, retention, selected use and exact event matching are reported separately. The optional comparison has no scoring-return channel. Exact agreement is not a production loss function.
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.
+56 -66
View File
@@ -1,94 +1,84 @@
# Reference-focused composer repair
# Complete-phrase solo rewrite
Starting revision: `f234dd6`. The goal of this revision is to make the active composer construct the musical relationships seen in the four supplied solos, using general rules and original song inputs. It does not import their notes or optimize candidate similarity to them. Exact reconstruction from general theory alone is not claimed.
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.
## Actual changes
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.
The active `themes → gestures → routes` path now chooses harmonic arrival timing, target pitch, sounding duration and release before pitch realization. It can construct an approach into an anticipated chord-tone target with both obligations present. Color destinations are available before route filling. Several compatible treatments can survive within one phrase; the local decoration stage preserves existing contracts instead of replacing them.
## Construction and simplification
On-time/delayed arrival pitches and their uninterrupted tails remain fixed through route filling and refinement. Other cell endings stay provisional. Internal long values can be reopened into eighth-note motion where the selected thematic contract permits it; exact rhythm identities and committed closing holds remain protected. Four-member chord traversals are available alongside the existing mapped paths.
The normal path is `production.generate_role → composer.compose → construction.Builder → rhythm.Rhythms/routes.Routes → scoring.evaluate → placement → validation → notation/export`.
Two general enclosure formulas use an actually mapped whole-tone upper neighbor. A natural sixth on tonic/predominant minor harmony can be a bounded stable color rather than always requiring immediate neighbor resolution. These rules retain written harmony, omissions, scale restrictions on ordinary notes and physical validation. No new scale families or performance effects were added.
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.
Reference inspection now includes every one of the 514 sounding events across the four complete scores. Original labels in technical-fingering and other-notation containers are retained. The review checks recognized treatments against **original source harmony**, preserves missing initial harmony, and keeps hypotheses separate from validated rules. Remaining unexplained events are reported instead of automatically authorized.
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 comparison location
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.
`build/solo_reference_comparison/` contains the compact previous four XMLs/manifest, current output, full reference comparison/audio, complete theory review and verification logs. Start with `index.html`; `analysis/theory.md` supplies the all-event analysis. Solo 1 means acoustic; Solo 2 means electric.
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 references are `build/all-of-me-solo-1.xml`, `all-of-me-solo-2.xml`, `autumn-leaves-solo-1.xml`, and `autumn-leaves-solo-2.xml`. Source songs remain authoritative in `data/songs/`. All six input hashes were recorded before work. The optional adapter applies the separately confirmed guitar-written minus-one-octave convention; production import is unchanged. Autumn Leaves aligns at measure 2 / first Gm7 and retains the preceding measure. Four Autumn electric reference events conflict with the production vocal cutoff; neither input is rewritten.
## Inputs and data
Cleanup removed 1.82 GiB of obsolete generated comparison trees/logs and five superseded tracked audition files. Exact removed paths and sizes are recorded in `cleanup.json`. Build toolchains, reusable composition/engraving caches, source songs and the four references were preserved. The old multi-experiment documentation was replaced with current commands and contracts.
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.
## Evidence and boundaries
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.
- [Active path and verified defects](active_path_audit.md)
- [Resource provenance, consumers and cleanup](data_migration.md)
- [Authoritative evaluation](evaluation_contract.md)
- [Musical capability and limits](musical_capability_matrix.md)
- [Independent input contract](dependency_proof.md)
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)
The six-role independent-input exercise passed with real catalogs: standalone, reversed, cold/warm, two-process, optional targets present/deleted, and after an electric-only setting change. Current hashes and actual read traces are in `dependency/dependency.json`. New proposal and route cache tests compare actual uncached/cached results and caller provenance. This does not establish perceptual quality.
`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.
The final score weights were preserved. Construction coverage is reported separately from selection under `selection_trace.joint_gestures`. A supported relationship may never be selected; matching a reference attack is a different fact again. Complete per-phrase evaluation totals remain inspectable.
## Remaining boundaries
The first diagnostic profile exposed repeated approach/rhythm/fretboard work. Bounded caches now reuse exact immutable constraints and restore each caller's provenance. No search beam was widened and no machine-speed timeout selects music. Full build costs and final test outcomes are recorded below; concurrent checks are not treated as an isolated benchmark.
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.
This remains a bounded rule-based composer. Some expressive reference relationships are unsupported or not recognized by the conservative optional parser; this is not proof those notes are musically wrong. Guitar placement is a legal-path check, not full ergonomic certification. No ear-based audition or listener approval is claimed.
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.
## Completed checks
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.
- Full Python suite: **576 tests passed in 1700.428s**. Command: repository Python runner plus `-m unittest discover -s tests -p 'test_*.py'`; full log in `build/solo_reference_comparison/tests.log`. This overlapped the full production build.
- Focused joint-arrival/evaluator/comparison tests: **33 passed**; eight comparison-adapter tests also passed after the presentation update.
- Six-role real-catalog dependency exercise: **passed**, including two spawned processes and reference presence/absence; evidence in `dependency/dependency.json`.
- `git diff --check`: passed at the pre-publication checkpoint.
## Published assets and measured cost
The comparison table aggregates timing-only retained-unit labels (such as diminution) into their direct construction family. The manifest preserves the original raw diagnostic labels. This reporting normalization does not select or edit music.
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.
## Published results and comparison
| 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 |
Normal production assets are published at `build/build_x86_debug/generated_assets/`. The four MusicXML exports in its `solo_xml/` directory are byte-identical to the copies in `build/solo_reference_comparison/generated/`.
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.
| Solo | New attacks | Sounding MIDI range | Exact matching reference attacks | Differing onset positions |
|---|---:|---|---:|---:|
| All of Me acoustic | 104 | 50–69 | 1 | 186 |
| All of Me electric | 100 | 51–67 | 3 | 187 |
| Autumn Leaves acoustic | 106 | 43–62 | 3 | 182 |
| Autumn Leaves electric | 97 | 55–70 | 2 | 170 |
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.
**None is an exact reference match.** These counts are descriptive, not evidence that the new music is closer perceptually. The earlier outputs had 0/3/0/1 exact matching attacks and 202/191/179/182 differing onset positions respectively. Different attack counts change these comparisons; they must not become a production optimization target.
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.
The new winners contain 9/12/13/13 validated expressive contracts respectively. All of Me electric selects one anticipation; the other three winners select none. Early-arrival candidates are constructed, validate and survive pruning in the traces, so their infrequent final selection is now visible separately from lack of capability. No quota was imposed to conceal that result.
| 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 |
All of Me acoustic contains a complete ascending A3–A#3–B3 connection at beats 12–13, and a mapped A3–F#3 approach to G3 at 64–65. All of Me electric sustains B3 as a valid minor ninth over its Am7 context at beats 28–31. These are selected events, not inserted examples or borrowed reference notes. `analysis/selected_phrases.md` records actual themes, full opening rhythms, every selected treatment, hold completion and alternative score breakdowns. Alternatives shown over equal spans are not falsely presented as the complete solo's globally ranked runners-up.
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.
The first All of Me acoustic theme still begins with a 4.5-beat source-derived rhythmic hold. This and the small number of selected anticipations demonstrate remaining distance from the supplied examples' phrase construction. The next narrow investigation is the interaction between retained source-rhythm contracts and complete-phrase selection: inspect valid arrival alternatives that lose, and source features that prevent editing a provisional opening. Do not respond by adding an enclosure quota, changing accompaniment, fitting reference counts or claiming that more vocabulary alone solved the problem.
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.
## Final verification
- All **12 role documents** agree between the cold staged build and the warm normal publication, including notes, timing and TAB. Evidence: `verification.json`.
- All eight non-solo role documents (accompaniment, electric source melody and vocal for both songs) match the previous release exactly as parsed JSON.
- All four MusicXML sounding streams match their selected production role documents. Full-duration harmonic validity, ending completion, and evaluation-contribution sums pass.
- The two original song files and four supplied references remain SHA-256 identical to their preserved inputs.
- Native command `ctest --test-dir build/build_x86_debug -R '^product_vamp$' --output-on-failure` passed in **33.54s**, with the required localhost socket access.
- The single comparison page contains 24 WAVs: complete generated/reference solos, identical-backing mixes, and opening excerpts. All passed PCM clipping checks. All 36 local page links exist. No subjective listening verdict is claimed.
## Measured cost
Hardware: Intel Core i7-1355U, 12 logical CPUs; two composition workers. Cold solo generation plus full staged asset build took **2249.06s (37.5 minutes)**. Regression and dependency checks overlapped part of that run, so this is not an isolated speed comparison with the earlier release.
| Cold solo role | Seconds |
|---|---:|
| All of Me acoustic | 1041.56 |
| All of Me electric | 850.48 |
| Autumn Leaves acoustic | 1328.58 |
| Autumn Leaves electric | 1170.46 |
Worker times overlap. Four All of Me non-solo caches were warm; the four Autumn Leaves non-solo roles regenerated in under one second each. Engraving rendered 90 new pages, reused two scores and took 16.65s wall time. Stage/input/read evidence is in `build-profile.json` and `build-inputs.json`.
The isolated warm CMake publication took **31.49s**, including about eight seconds of CMake/configuration overhead; asset generation reported 23s. All twelve role caches and all six engraving results were reused. Musical equality was verified, not assumed from cache-hit flags.
An isolated 30-repeat structural-proposal microbenchmark returned exactly equal results: **0.4422s** without the proposal cache versus **0.02193s** with it (shared rhythm/position caches already warm). This is a repeated-calculation measurement, **not** a claimed whole-composer speedup. Cold search is still expensive; candidate construction/validation and repeated thematic alternatives remain performance work.
After verification, duplicate full asset stages, two interrupted stages, temporary tone samples and the binary diagnostic profile were removed. The comparison retains the four current scores, compact previous scores, references through links, useful audio and verification evidence in one directory. No previous output is a generation input.
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.
+16 -17
View File
@@ -1,22 +1,21 @@
# Capabilities and boundaries
# Demonstrated capabilities and limits
| Relationship | Active support | Limit |
| Behavior | Active implementation | Evidence |
|---|---|---|
| Theme/return | Source-derived feature and actual home realization | Technical similarity does not prove recognition |
| Mapped/chord movement | Complete sequences, chord traversal, contract-conditioned routes | Bounded search can miss alternatives |
| Anticipation | Joint arrival/hold and future-harmony contract | Bounded early window |
| On-time/delayed arrival | Selected pitch and uninterrupted tail | No compulsory arrival per cell |
| Approach into anticipation | Shared exact future target, both contracts | Wrong octave/changed target rejects |
| Enclosure | Both orders, semitone or mapped whole-tone upper | Must surround under the exact rule |
| Minor sixth | Bounded stable tonic/predominant color | Written semantics/function authoritative |
| Other colors/suspensions/blues | Existing explicit policies | Annotation is not permission |
| Rhythm | Coherent binary/tuplet groups and editable internal subdivision | No arbitrary tuplet slicing |
| Guitar | Strings 1–6, frets 1–12, no open strings | Not full ergonomic certification |
| Accompaniment | Independent canonical sparse comping | No reactive solo-gap filling |
| Reference review | All events, original labels, rule recognition | Observations distinguished from validated support |
| 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 |
Independent tests in `tests/test_joint_arrivals.py` extend the existing timing, evaluator, importer, physical, expression and dependency coverage. The four target files are not required fixtures.
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.
All four target solos have legal positions under the explicitly confirmed guitar-written octave convention. Four Autumn electric events extend beyond the source-derived production vocal cutoff. This is reported, not repaired by changing either input.
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.
Unexplained reference events remain explicit. Recognizing a relationship does not inject it into construction; selecting more expression contracts does not prove a better melody. Audition remains necessary.
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.
+1 -1
View File
@@ -103,7 +103,7 @@ def generate_assets(args: argparse.Namespace) -> None:
roles=json.loads(path.read_text())
for role in roles.values():
trace=role.get('details',{}).get('selection_trace',{})
trace.pop('evaluations',None);trace.pop('source_candidate_pools',None)
trace.pop('decisions',None)
path.write_text(json.dumps(roles,sort_keys=True,indent=2))
if getattr(args, 'export_solo_xml', False):
from generator.solo.export import export_tree
+7 -6
View File
@@ -78,7 +78,7 @@ def windows(document, part):
def dependencies(role, resources):
names = ('grammar', 'harmony', 'lead_ins', 'rhythms', 'melodic_vocabulary') if role.endswith('_solo') else ('rhythms',) if role.endswith('_accompaniment') else ()
names = ('solo', 'harmony', 'lead_ins', 'rhythms') if role.endswith('_solo') else ('rhythms',) if role.endswith('_accompaniment') else ()
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
**{name: resources[name] for name in names}}
@@ -181,7 +181,7 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
trace={'source':source.musical_key,'role':role,'production_resources':inputs['catalogs'],
'vocabulary':vocabulary.fingerprint(inputs['vocabulary']),'settings':settings,'algorithm':algorithm,'code':inputs['code'],'cache_key':key}
seed=vocabulary.fingerprint({'source':source.musical_key,'role':role,'settings':settings,'data':inputs['vocabulary'],'version':algorithm})
details={}; solo_report=''
details={}; solo_report=''; composition_timings={}
with vocabulary.using(resources,role,settings|{'choice_seed':seed}):
if role.endswith('_solo'):
result=generate._arrange_document(document,part,{},owned={'slash'})
@@ -198,7 +198,8 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
scale_library,sequence_library,rhythm_library=_solo_libraries(data_dir,tuple(sorted(inputs['catalogs'].items())))
solo=composer.compose(analysis.analyze(document),template,part,end,
initial=initial,scales=scale_library,sequences=sequence_library,rhythms=rhythm_library,
enclosures_enabled=vocabulary.role().get('expressions',True))
expressions_enabled=vocabulary.role().get('expressions',True))
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)
details=report.structure(solo)
@@ -217,7 +218,7 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
staff['tuplets']=score.reidentify(staff['measures'],staff.get('tuplets',[]))
if part!='vocal':result['staves'][0]['tablature']=True
model.validate_document(result);fretboard.validate_document(result)
value={'role':role,'document':result,'trace':trace,'details':details,'report':solo_report,'seconds':time.monotonic()-started,'cache_hit':False}
value={'role':role,'document':result,'trace':trace,'details':details,'report':solo_report,'seconds':time.monotonic()-started,'composition_timings':composition_timings,'cache_hit':False}
# Sidecar diagnostics use the same rational-safe representation as reports.
# Identical insertion order after cold generation and cache loading also
# keeps serialized scores and the catalog byte-for-byte reproducible.
@@ -275,7 +276,7 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
results={p:{} for p in sources}
def accept(path,role,value):
results[path][role]=value
if timings is not None:timings.append({'source':str(path),'role':role,'seconds':value['seconds'],'cache_hit':value['cache_hit'],'observed_reads':value.get('observed_reads',[])})
if timings is not None:timings.append({'source':str(path),'role':role,'seconds':value['seconds'],'cache_hit':value['cache_hit'],'composition_timings':value.get('composition_timings',{}),'observed_reads':value.get('observed_reads',[])})
if progress:print(f'Composition: {path.stem}/{role}: '+('cached' if value['cache_hit'] else f'{value["seconds"]:.1f}s'),flush=True)
if jobs==1:
for path,role,args in requests:
@@ -296,6 +297,6 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
analysis_dir=output_dir.parent/'analysis';analysis_dir.mkdir(parents=True,exist_ok=True)
(analysis_dir/(score.slugify(path.stem)+'.txt')).write_text('\n'.join(results[path][r]['report'] for r in ROLES))
# Not a score file: diagnostics are deliberately outside the songs schema.
stable={role:{k:v for k,v in value.items() if k not in ('seconds','cache_hit','observed_reads')} for role,value in results[path].items()}
stable={role:{k:v for k,v in value.items() if k not in ('seconds','composition_timings','cache_hit','observed_reads')} for role,value in results[path].items()}
(analysis_dir/(score.slugify(path.stem)+'.roles')).write_text(json.dumps(stable,sort_keys=True,indent=2))
return len(sources)*3
+11 -104
View File
@@ -1,33 +1,14 @@
"""Immutable source events, meter boundaries and tempo. No solo construction."""
from __future__ import annotations
import dataclasses
import fractions
import hashlib
import json
import generator.harmony
import generator.model
import generator.score
import generator.solo.types
import dataclasses, fractions
import generator.model, generator.score, generator.solo.types
@dataclasses.dataclass(frozen=True)
class Analysis:
motifs: tuple[generator.solo.types.Motif, ...]
boundaries: tuple[fractions.Fraction, ...]
boundaries: tuple
tempo: fractions.Fraction
melody: tuple[generator.solo.types.Note, ...]
melody: tuple
body_offset: fractions.Fraction = fractions.Fraction()
source_phrases: tuple[generator.solo.types.SourcePhrase, ...] = ()
def identity(analysis, measures, part):
anchor = analysis.melody[0].pitch.number() if analysis.melody else 0
melody = [(str(n.start), str(n.end), n.pitch.number() - anchor) for n in analysis.melody]
harmony = []
for measure in measures:
for chord in measure['harmonies']:
pitches = sorted((int(p) - anchor) % 12 for p in generator.harmony.pitch_classes(chord)) if chord['kind'] != 'none' else []
harmony.append((chord['kind'], pitches, chord['onset']))
return hashlib.sha256(json.dumps((part, melody, harmony), sort_keys=True).encode()).hexdigest()
def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]:
"""Collapse tied tones before extracting monophonic melody fragments."""
@@ -55,83 +36,9 @@ def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]:
absolute += generator.model.fraction(measure['duration'])
return tuple(sorted(result, key=lambda n: (n.start, -n.pitch.number(), n.sources)))
def analyze(document: dict) -> Analysis:
measures = generator.score.source_staff(document)['measures']
melody = attacks(document)
boundaries = [fractions.Fraction()]
for measure in measures:
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
tempo = next((generator.model.fraction(t['bpm']) * generator.model.written_length(t['written']) for m in measures for t in m.get('tempos', [])), fractions.Fraction(120))
candidates = []
for index, measure in enumerate(measures):
start = boundaries[index]
# Legacy motif consumers also receive complete longer source material.
for count in (1, 2, 4, 6, 8):
if index + count >= len(boundaries):
continue
end = boundaries[index + count]
notes = tuple(n for n in melody if start <= n.start < end)
if not notes or any(n.end > end for n in notes) or any(a.end > b.start for a, b in zip(notes, notes[1:])):
continue
normalized = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in notes)
identity = repr(tuple((n.start, n.end, n.pitch) for n in normalized)).encode('ascii')
identifier = 'head_' + hashlib.sha256(identity).hexdigest()[:12]
context = tuple(generator.harmony.symbol(h) for m in measures[index:index + count] for h in m['harmonies'])
candidates.append(generator.solo.types.Motif(identifier, normalized, end - start, context))
unique = {m.id: m for m in reversed(candidates)}
# Preserve the opening idea, then favor a contrasting rhythm/contour with space.
ordered = list(unique.values())
ordered.sort(key=lambda m: (next(i for i, n in enumerate(melody) if n.sources[0] == m.notes[0].sources[0]), m.duration))
offset = boundaries[generator.score.body_start_measure_index(measures)]
phrases = source_phrases(melody, offset, boundaries[-1] - offset,
generator.model.fraction(measures[-1]['duration']))
return Analysis(tuple(ordered), tuple(boundaries), tempo, melody, offset, phrases)
def source_phrases(melody, offset, end, bar):
"""Partition at musical releases, without slicing a sounding event at bars."""
notes = tuple(dataclasses.replace(n, start=n.start-offset, end=n.end-offset,
event_id=n.sources[0]) for n in melody if n.end > offset)
if any(a.end > b.start for a, b in zip(notes, notes[1:])):
raise ValueError('solo source requires a monophonic melody')
candidates = sorted({fractions.Fraction(), end, *(n.end for n in notes if 0 < n.end <= end),
*(n.start for n in notes if 0 < n.start < end)})
candidates = [t for t in candidates if not any(n.start < t < n.end for n in notes)]
if not candidates or candidates[0] != 0:
candidates.insert(0, fractions.Fraction())
best = {end: (0., ())}
for start in reversed(candidates[:-1]):
options = []
for finish in candidates:
duration = finish-start
if finish not in best or duration <= 0 or duration > 8*bar or duration < 2*bar and finish != end:
continue
prior = next((n for n in reversed(notes) if n.end <= finish), None)
after = next((n for n in notes if n.start >= finish), None)
gap = float(max(0, after.start-prior.end)) if prior and after else 0.
held = float(prior.end-prior.start) if prior else 0.
cost = abs(float(duration/bar)-4)/4 + .7*(held < 2) - .3*min(2,gap)
options.append((cost+best[finish][0], (finish,*best[finish][1])))
if options:
best[start] = min(options)
if 0 not in best:
# A single extended held event is one musical unit, regardless of length.
stops = (end,)
else:
stops = best[0][1]
result, start = [], fractions.Fraction()
for finish in stops:
actual = tuple(n for n in notes if n.start < finish and n.end > start)
cursor, rests = start, []
for n in actual:
if cursor < n.start:
rests.append((cursor,n.start))
cursor = n.end
if cursor < finish:
rests.append((cursor,finish))
origin = actual[0].pitch.number() if actual else 0
shape = tuple((n.start-start,n.end-start,n.pitch.number()-origin) for n in actual)
family = hashlib.sha256(repr(shape).encode()).hexdigest()[:12]
pickup = tuple(dataclasses.replace(n,start=n.start-offset,end=n.end-offset,event_id=n.sources[0]) for n in melody if n.end<=offset) if start==0 else ()
result.append(generator.solo.types.SourcePhrase(f'source_{start}_{finish}',start,finish,actual,tuple(rests),family,pickup))
start = finish
return tuple(result)
def analyze(document):
measures=generator.score.source_staff(document)['measures']
boundaries=[fractions.Fraction()]
for m in measures:boundaries.append(boundaries[-1]+generator.model.fraction(m['duration']))
tempo=next((generator.model.fraction(t['bpm'])*generator.model.written_length(t['written']) for m in measures for t in m.get('tempos',[]) if 'bpm' in t),fractions.Fraction(120))
return Analysis(tuple(boundaries),tempo,attacks(document),boundaries[generator.score.body_start_measure_index(measures)])
-55
View File
@@ -37,58 +37,3 @@ def annotate(document, solo, offset):
else:
spans.append({'id': f'{solo.part}_scale_{len(spans) + 1}', 'start': generator.model.rational(offset + choice.span.start), 'end': generator.model.rational(offset + choice.span.end), 'label': label, 'matched': True})
document['staves'][0]['scale_spans'] = spans
def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None, references=(), stage='elaborated', enclosures_enabled=True):
"""Compose once per song, replacing only already-placed solo section windows."""
if references:
raise ValueError('Audition references are not production composition inputs')
from generator.arrange import generate
analysis = generator.solo.analysis.analyze(source)
sections = regions(source)
source_measures = generator.score.source_staff(source)['measures']
reports = []
requests = {}
for section_index, part in ((3, 'acoustic'), (4, 'electric')):
first, count, offset, length = sections[section_index]
form = generator.arrange.form.full_arrangement_sections(source_measures, part)
section = form[section_index]
effective = generate.section_source(source_measures, section, form[section_index+1])
initial = generator.accompaniment.carried_chords(effective)[section.source_measure_index]
template = copy.deepcopy(effective[section.source_measure_index:section.source_measure_index+count])
cutoff = length
if part == 'electric' and generator.score.body_start_measure_index(source_measures):
pickup = source_measures[0]
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']]
if sounding:
cutoff -= generator.model.fraction(pickup['duration']) - min(sounding)
requests[part] = (template, cutoff, initial)
selected = generator.solo.composer.compose_pair(analysis, requests, scales=scales, sequences=sequences, rhythms=rhythms, contexts=contexts, stage=stage,enclosures_enabled=enclosures_enabled)
for solo, section_index, other in zip(selected, (3, 4), ('electric', 'acoustic')):
part = solo.part
first, count, offset, length = sections[section_index]
template, cutoff, initial = requests[part]
if solos is not None:
solos.append(solo)
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
generator.solo.notation.replace_region(parts[part], first, foreground)
annotate(parts[part], solo, offset)
# The other instrument already contains its canonical accompaniment.
# Installing a solo must never rewrite the learned supporting part.
# Canonical supporting notes must remain compatible throughout every
# overlapped mapped span, independent of the foreground realization.
elapsed = fractions.Fraction()
for measure in parts[other]['staves'][0]['measures'][first:first+count]:
for event in generator.score.measure_notes(measure):
start = elapsed + generator.model.fraction(event['onset'])
end = min(cutoff, start + generator.model.fraction(event['duration']))
if start >= end: continue
allowed = generator.solo.types.permitted(solo.choices,start,end)
if any(int(generator.model.pitch_number(p)) % 12 not in allowed for p in event['pitches']):
raise ValueError(f'{other} accompaniment, measure {measure["number"]}: canonical voicing outside solo map')
elapsed += generator.model.fraction(measure['duration'])
solo.diagnostics['canonical_accompaniment'] = generator.arrange.accompaniment.audit(source, parts)
reports.append(generator.solo.report.describe(solo, scales, first, offset))
for document in parts.values():
generator.model.validate_document(document)
generator.fretboard.validate_document(document)
return '\n'.join(reports)
+40 -129
View File
@@ -1,136 +1,47 @@
from __future__ import annotations
import dataclasses
import fractions
"""The single production entry point for independent phrase composition."""
from fractions import Fraction as F
import time
import dataclasses
from generator import harmony as chords, vocabulary
from generator.scales import timeline, selection
from generator.solo import types, profiles, harmonic, construction, placement, policy
import generator.solo.enclosures
import generator.solo.profiles
import generator.fretboard
import generator.harmony
import generator.model
import generator.scales.selection
import generator.scales.timeline
import generator.solo.harmonic
import generator.solo.analysis
import generator.solo.planning
import generator.solo.policy
import generator.solo.realization
import generator.solo.types
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated', mode='thematic', enclosures_enabled=True):
if references or context is not None and getattr(context,'references',()):
raise ValueError('Audition references are not production composition inputs')
started = time.monotonic()
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
choices = generator.scales.selection.select(timeline, region, scales)
for choice in choices:
if choice.span.chord and choice.scale is None:
raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog')
from generator.solo import leads
context = context if context is not None else generator.solo.realization.SearchContext()
context.expressions_enabled = enclosures_enabled
options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode)
result = options[0][1]
result.diagnostics['strict_composition_seconds'] = time.monotonic()-started
from generator.solo import expressions
if mode!='thematic':result = expressions.refine(result,False)
result.diagnostics['enclosures_enabled']=enclosures_enabled
result.diagnostics['expressions_selected']=[e.kind for p in result.phrases for e in p.draft.expressions]
def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,expressions_enabled=True):
began=time.monotonic()
choices=selection.select(timeline.read({'measures':measures},initial=initial),timeline.Solo(F(),end,part.capitalize()+' Solo'),scales)
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)
phrases=builder.compose()
# Resolve hand-position transitions over the selected whole melody. This
# cannot reselect pitches, rhythm or another independently generated part.
raw=tuple(n for p in phrases for n in p.notes)
placed=placement.place(raw,analysis.tempo,profile=profile) if raw else (0,())
if placed is None:raise construction.SearchExhausted('selected melody has no complete legal guitar path')
by_id={n.event_id:n for n in placed[1]}
phrases=tuple(dataclasses.replace(p,notes=tuple(by_id[n.event_id] for n in p.notes)) for p in phrases)
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'complete_phrases','version':policy.DEFAULT.composition_version,
'construction_seconds':time.monotonic()-began,**builder.timings,'operation_coverage':{k:dict(v) for k,v in builder.coverage.items()},
'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile)
validate(result)
return result
def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic', enclosures_enabled=True):
"""Compatibility batch helper: each winner is selected independently."""
return tuple(compose(analysis, measures, part, end, scales=scales, sequences=sequences,
rhythms=rhythms, initial=initial, context=(contexts or {}).get(part), stage=stage,
mode=mode, enclosures_enabled=enclosures_enabled)
for part, (measures, end, initial) in requests.items())
def phrase_notes(phrase):
notes = tuple(n for d in phrase.decisions for n in d.notes)
if notes and notes[0].continuation:
notes = (dataclasses.replace(notes[0], continuation=False), *notes[1:])
return generator.solo.types.sounding_notes(notes)
def transition_cost(first, second):
before, after = phrase_notes(first), phrase_notes(second)
if not before or not after:
return 0.0
gap = max(0, after[0].start - before[-1].end - first.plan.release)
jump = abs(after[0].pitch.number() - before[-1].pitch.number())
# The next phrase's local score already includes physical movement. Here we
# evaluate pacing and conversational contrast at the join, once per pair.
before_shape = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(before, before[1:]))
after_shape = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(after, after[1:]))
repeated_answer = second.plan.relationship == 'answer' and before_shape == after_shape
return min(1, float(gap) / 2) + 0.5 * min(1, max(0, jump - 5) / 12) + 0.5 * repeated_answer
def choose_phrases(plans, realize, revise_pair=None):
"""Commit only after the next phrase has been evaluated from each ending."""
result, previous, pending = [], None, None
for index, plan in enumerate(plans):
options = realize(plan, previous)
if pending is not None and pending not in options:
options = (pending, *options)
ranked, failure = [], None
for option in options:
notes = phrase_notes(option)
last = notes[-1] if notes else previous
incoming = transition_cost(result[-1], option) if result else 0
if index + 1 == len(plans):
ranked.append((option.score + incoming, option, None))
continue
try:
following = realize(plans[index + 1], option.state or last)
except generator.solo.realization.SearchExhausted as error:
failure = error
continue
length = float(option.plan.end - option.plan.start)
next_length = float(plans[index + 1].end - plans[index + 1].start)
shared_passage = bool(plan.passage_id and plan.passage_id == plans[index + 1].passage_id)
for future in following:
left, right = revise_pair(option, future, previous) if revise_pair else (option, future)
cost = (right.score if shared_passage else (left.score * length + right.score * next_length) / (length + next_length)) + transition_cost(left, right)
ranked.append((cost + incoming, left, right))
if not ranked:
raise failure or generator.solo.realization.SearchExhausted(f'{plan.id}: no feasible phrase transition')
_, chosen, pending = min(ranked, key=lambda item: (item[0], tuple((n.pitch, n.start, n.end, n.position) for n in phrase_notes(item[1]))))
result.append(chosen)
notes = phrase_notes(chosen)
if chosen.state:
previous = chosen.state
elif notes:
previous = notes[-1]
return tuple(result)
def validate(solo):
previous = None
profile = solo.profile or generator.solo.profiles.for_part(solo.part)
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(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
if note.position not in generator.solo.placement.positions(note.pitch.number(), profile):
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
previous = note
for phrase in solo.phrases:
notes = phrase_notes(phrase)
if not generator.solo.realization.target_ok(notes, phrase.plan.target):
raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized')
harmony = generator.solo.harmonic.Harmony(solo.choices)
contracts = tuple(dict.fromkeys(t for phrase in solo.phrases if phrase.state for t in phrase.state.treatments))
enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures)
expressions = tuple(e for p in solo.phrases if p.draft for e in p.draft.expressions)
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures,expressions=expressions)
if not checked.valid:
raise ValueError(f'{solo.part} solo: {checked.error}')
for phrase in solo.phrases:
if phrase.state:
complete = phrase.plan.end == (phrase.plan.passage_end or phrase.plan.end)
valid, error = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=complete, closure=phrase.plan.closure)
if not valid:
raise ValueError(error)
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.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()
for p in solo.phrases:
if p.start!=cursor or p.end<=p.start:raise ValueError('phrase coverage gap or overlap')
cursor=p.end
valid,error=harmonic.anchors_valid((p.destination,) if p.destination else (),p.notes,p.end,complete=True,closure='closed' if p.ending in ('complete','handoff') else 'open')
if not valid:raise ValueError(error)
if cursor!=solo.end:raise ValueError('incomplete solo duration')
+230
View File
@@ -0,0 +1,230 @@
"""Construct complete phrases directly from harmonic/rhythmic purposes.
The head is optional material, never a compulsory prefix or rhythmic template.
No reference score, completed part, or previous published output enters here.
"""
import dataclasses
import time
from collections import Counter
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
class SearchExhausted(ValueError):pass
def choose(values,seed,domain):
return min(values,key=lambda v:policy.stable_rank(seed,(domain,v)))
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 at(self,time):return next((m for m in self.windows if m[0]<=time<m[1]),self.windows[-1])
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 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)
-414
View File
@@ -1,414 +0,0 @@
"""Phrase-wide rhythm construction and contracts for concrete melodic drafts.
No bar winners are assembled here. A rhythm is stated, transformed in place,
and timed to its destinations before the pitch search begins.
"""
from __future__ import annotations
import dataclasses
import fractions
import functools
import generator.model
import generator.solo.harmonic
import generator.solo.policy
import generator.solo.scoring
import generator.solo.sequences
import generator.solo.types
import generator.solo.features
F = fractions.Fraction
@dataclasses.dataclass(frozen=True)
class Slot:
id: str
start: F
end: F
group: int
reference: int
forced: generator.solo.types.Pitch | None = None
protected: bool = False
@dataclasses.dataclass(frozen=True)
class Group:
id: str
operation: str
start: F
end: F
source: str
@dataclasses.dataclass(frozen=True)
class RhythmDraft:
id: str
slots: tuple[Slot, ...]
groups: tuple[Group, ...]
anchors: tuple[generator.solo.types.Anchor, ...]
reference: generator.solo.types.Motif | generator.solo.types.RealizedMotif
relationship: str
octave: int = 0
def catalog(library, meter):
return _catalog(library.for_meter(meter))
@functools.lru_cache(maxsize=32)
def _catalog(snippets):
"""Read complete snippets; ties across authored bars stay one attack."""
result = {}
for snippet in snippets:
offset, spans = F(), []
for bar in snippet.bars:
for event in bar.events:
if not event.sounding:
continue
start, end = offset + event.onset, offset + event.onset + event.duration
if event.continuation:
if spans and spans[-1][1] == start:
spans[-1] = (spans[-1][0], end)
else:
spans.append((start, end))
offset += bar.length
if 2 <= len(spans) <= 12:
result.setdefault(tuple(spans), (snippet.id, offset))
return tuple((identifier, spans, length) for spans, (identifier, length) in result.items())
def templates(plan, reference, library, meter):
source = tuple((n.start, n.end) for n in reference.notes)
if plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
return tuple([(reference.id, source, reference.duration)])
# A compact identifying portion, including its authored internal rests.
source = source[:6]
result = []
if source:
length = min(reference.duration, source[-1][1] + F(1, 2))
result.append((reference.id, source, length))
choices = catalog(library, meter)
ranked = generator.solo.sequences.diverse(choices,
lambda item: (abs(len(item[1]) / float(item[2]) - float(plan.activity)) > 0.75, generator.solo.policy.stable_rank(plan.seed, item)),
lambda item: (len(item[1]), bool(item[1][0][0]), item[2]), 4)
result.extend(ranked)
return tuple(result)
def reopen(slots, cursor):
"""An extension changes the previous provisional ending, not its core."""
if not slots:
return slots, cursor
last = slots[-1]
if last.protected or last.forced:
return slots, cursor
end = max(last.start + F(1, 2), last.end - F(1, 2))
return (*slots[:-1], dataclasses.replace(last, end=end)), end
def develop_timing(pattern, period, unit, occupancy, connect):
"""Diminish/broaden one established rhythm, retaining order and rests.
Ordinary diminution stops at eighth notes. Connected development may
broaden existing sounds into part of their following rest, not insert a new
independent pattern. These choices precede pitch/harmonic realization.
"""
result, cursor, previous = [], F(), F()
for start, end in pattern:
cursor += (start - previous) * unit
duration = max(F(1, 2), (end - start) * unit) if unit < 1 else (end - start) * unit
result.append((cursor, cursor + duration))
cursor += duration
previous = end
length = cursor + (period - previous) * unit
if connect:
needed = max(F(), occupancy * length - sum(b - a for a, b in result))
for i, (a, b) in enumerate(result):
following = result[i + 1][0] if i + 1 < len(result) else length
# Occupancy is a soft objective, not a duration denominator. Use a half
# beat or the existing rest endpoint; never manufacture e.g. 7/40 beats
# solely to hit a percentage exactly.
extra = min(F(1, 2), following - b)
if abs(needed - extra) >= needed:
extra = F()
result[i] = (a, b + extra)
needed -= extra
return tuple(result), length
def ending_options(anchors, plan, choices):
if not anchors or plan.target or plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
return tuple([anchors])
terminal = next((a for a in anchors if a.role == 'terminal'), None)
if terminal is None or terminal.hold_until is None:
return tuple([anchors])
passage_end = plan.passage_end or plan.end
active = [c for c in choices if c.scale and c.span.start < passage_end and c.span.end > plan.start]
if not active:
return tuple([anchors])
# Missing harmony is mandatory space. Retiming an ordinary ending must not
# move its destination into a trailing N.C. span.
harmonic_end = min(passage_end, active[-1].span.end)
result = []
for release in (F(), F(1, 2), F(1)):
finish = harmonic_end - release
last = next((c for c in choices if c.span.start < finish <= c.span.end), None)
for duration in (F(1, 2), F(1), F(2), F(3)):
start = finish - duration
if last is None or start < max(last.span.start, plan.passage_start or plan.start) or any(a.role != 'terminal' and a.latest + a.duration > start for a in anchors):
continue
changed = dataclasses.replace(terminal, earliest=start, latest=start, duration=duration, hold_until=finish)
result.append(tuple(changed if a.id == terminal.id else a for a in anchors))
return tuple(result) or tuple([anchors])
def ending_plan(plan, anchors):
if plan.target or plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
return plan
terminal = next((a for a in anchors if a.role == 'terminal'), None)
if terminal is None or terminal.hold_until is None:
return plan
finish = plan.passage_end or plan.end
release = finish - terminal.hold_until
# The candidate chooses where arrival begins. Earlier portions remain active.
spans = []
for span in plan.passages:
low, high = span.start, min(span.end, terminal.earliest)
if low < high:
spans.append(dataclasses.replace(span, end=high, intent='active', occupancy=F(str(generator.solo.policy.DEFAULT.active_occupancy))))
if not spans and plan.start < terminal.earliest:
spans.append(generator.solo.types.ActivitySpan(plan.passage_start if plan.passage_start is not None else plan.start, terminal.earliest, 'active', plan.activity, F(str(generator.solo.policy.DEFAULT.active_occupancy))))
arrival_length = terminal.hold_until - terminal.earliest
desired = max(F(), plan.activity * (finish - (plan.passage_start if plan.passage_start is not None else plan.start)) - 1)
expected = sum(s.activity * (s.end - s.start) for s in spans)
spans = [dataclasses.replace(s, activity=min(F(2), s.activity * desired / expected) if expected else plan.activity) for s in spans]
spans.append(generator.solo.types.ActivitySpan(terminal.earliest, terminal.hold_until, 'sustained_arrival', 1 / arrival_length, F(str(generator.solo.policy.DEFAULT.arrival_occupancy))))
if release:
spans.append(generator.solo.types.ActivitySpan(terminal.hold_until, finish, 'space', F(), F()))
complete = plan.end == finish
return dataclasses.replace(plan, release=release if complete else F(), passages=tuple(spans), ending=('breath' if release else 'sustain') if complete else 'connected')
def align_anchors(slots, anchors, plan):
"""Choose arrival timing with the rhythm, before assigning any free pitches."""
slots = list(slots)
exact = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
protected_end = plan.start + plan.motif.duration if exact else plan.start
for anchor in anchors:
if not plan.start <= anchor.earliest < plan.end - plan.release:
continue
possible = [s for s in slots if anchor.earliest <= s.start <= anchor.latest]
fit = [s for s in possible if s.end - s.start >= anchor.duration]
selected = (fit or possible)
slot = selected[0] if selected else None
time = slot.start if slot else anchor.earliest
end = anchor.hold_until or time + anchor.duration
end = min(end, plan.end - plan.release)
if time < protected_end:
if slot is None or slot.end < end or (slot.forced and slot.forced != anchor.pitch):
return None
slots[slots.index(slot)] = dataclasses.replace(slot, forced=anchor.pitch)
continue
group = slot.group if slot else next((s.group for s in reversed(slots) if s.start <= time), 0)
event_id = slot.id if slot else anchor.id
replacement = Slot(event_id, time, end, group, slot.reference if slot else 0, anchor.pitch)
before = [dataclasses.replace(s, end=min(s.end, time)) for s in slots if s.start < time]
slots = [s for s in before if s.end > s.start] + [replacement] + [s for s in slots if s.start >= end and s.start != time]
return tuple(slots)
def construct(plan, reference, relationship, template, anchors, variant, choices, octave=0):
identifier, pattern, period = template
if not pattern or period <= 0:
return None
finish = plan.end - plan.release
slots, groups, cursor = (), [], plan.start
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
while cursor < finish:
index = len(groups)
# Establish the idea before departing. Choose from the remaining musical
# span instead of cycling a fixed list of transformations indefinitely.
remaining = finish - cursor
operation = relationship if index == 0 else 'repeat' if index == 1 and variant == 0 else 'answer' if remaining <= period else 'extend' if variant == 1 and index == 1 else 'recall'
activity = next((p for p in plan.passages if p.start <= cursor < p.end), None)
rate = F(len(pattern), 1) / period
unit = F(1) # Rhythmic development is musical intent, never a density repair.
local = pattern
length = period
if index and operation == 'extend':
slots, cursor = reopen(slots, cursor)
# Continue a recognizable opening beyond the old cadence; this group
# can span either side of a barline without a new catalog selection.
tail = pattern[:2]
local = (*pattern, *((period + a, period + b) for a, b in tail))
length += tail[-1][1]
elif index and operation == 'answer':
local = pattern[:max(2, len(pattern) - 1)]
length = local[-1][1]
elif index and operation == 'recall':
operation = 'repeat_with_harmonic_adaptation'
elif index and operation == 'displace':
local = tuple((a + F(1, 2), b + F(1, 2)) for a, b in pattern)
length += F(1, 2)
if index and unit == 2:
operation = 'fragment'
local = local[:3]
length = local[-1][1]
if index and not protected:
connected = operation not in ('repeat', 'repeat_with_harmonic_adaptation')
local, length = develop_timing(local, length, unit, activity.occupancy if activity else F(3, 4), connected)
if unit < 1:
operation = 'diminish' if operation != 'extend' else 'extend_diminished'
elif unit > 1:
operation = 'broaden_ending'
unit = F(1)
if activity and activity.intent == 'space':
cursor = min(activity.end, finish)
continue
if cursor + unit * length > finish:
operation = 'close' if index else operation
group_end = min(finish, cursor + unit * length)
if group_end <= cursor:
break
group = Group(f'{plan.id}_development_{index + 1}', operation, cursor, group_end, identifier)
groups.append(group)
new = []
for j, (a, b) in enumerate(local):
start, end = cursor + a * unit, min(group_end, cursor + b * unit)
if start >= end:
continue
quote = protected and index == 0
pitch = None
if quote and plan.treatment in ('quote', 'exact_quote'):
pitch = dataclasses.replace(reference.notes[j].pitch, octave=reference.notes[j].pitch.octave + octave)
new.append(Slot(f'{group.id}_event_{j + 1}', start, end, index, j % len(pattern), pitch, quote))
slots = (*slots, *new)
cursor = group_end
if protected and plan.start + reference.duration > finish:
return None
aligned = align_anchors(slots, anchors, plan)
if aligned is None:
return None
# Missing harmony is explicit space. Only ordinary material may fragment.
filtered = []
for slot in aligned:
pieces = [(slot.start, slot.end)]
for choice in choices:
if choice.scale is None:
pieces = [(a, b) for low, high in pieces for a, b in ((low, min(high, choice.span.start)), (max(low, choice.span.end), high)) if b > a]
if slot.protected and pieces != [(slot.start, slot.end)]:
return None
filtered.extend(dataclasses.replace(slot, id=slot.id + (f'_fragment_{i}' if len(pieces) > 1 else ''), start=a, end=b) for i, (a, b) in enumerate(pieces))
# The finalized rhythm is the contract, including changes made by extension
# and anchor alignment. It is never rebased when a cell or bar ends.
identity = generator.solo.policy.stable_rank(plan.seed, (identifier, variant, anchors, relationship, octave))[:12]
return RhythmDraft(identity, tuple(filtered), tuple(groups), anchors, reference, relationship, octave)
def rhythms(plan, incoming, measures, choices, library, batch=0, reference_library=()):
continuing = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
anchors = (incoming.selected_anchors,) if continuing and incoming.selected_anchors else plan.anchor_options or tuple([()])
references = [(plan.motif, 'statement')]
if incoming.home and plan.treatment not in ('quote', 'exact_quote', 'rhythmic_reference'):
references = [(incoming.home, 'continue' if incoming.ending == 'connected' or continuing else 'answer')]
if not continuing:
references.append((incoming.home, 'recall'))
if plan.motif.id != incoming.home.parent:
references.append((plan.motif, 'contrast'))
if incoming.themes and incoming.themes[0].id != incoming.home.id:
references.append((incoming.themes[0], 'recall'))
if plan.treatment not in ('quote', 'exact_quote', 'rhythmic_reference') and not continuing:
references.extend((motif, 'contrast' if incoming.home else 'statement') for example in reference_library for motif in example.windows())
elapsed = F()
meter = measures[0]['time_signature']
for measure in measures:
end = elapsed + generator.model.fraction(measure['duration'])
if elapsed <= plan.start < end:
meter = measure['time_signature']
break
elapsed = end
result = []
for reference, relationship in references:
available_templates = templates(plan, reference, library, meter)
if isinstance(reference, generator.solo.types.RealizedMotif) or reference.origin == 'accepted reference profile':
available_templates = tuple([(reference.id, tuple((n.start, n.end) for n in reference.notes), reference.duration)])
for template in available_templates:
for backbone in anchors:
endings = (backbone,) if continuing else ending_options(backbone, plan, choices)
for ending in endings:
for variant in range(2):
for octave in ((0, 1, -1, 2, -2) if plan.treatment in ('quote', 'exact_quote') else tuple([0])):
rhythm = construct(ending_plan(plan, ending), reference, relationship, template, ending, variant, choices, octave)
if rhythm:
result.append(rhythm)
def rank(rhythm):
expected = float(plan.activity * (plan.end - plan.start))
# Broad rhythm feasibility only. Pitch/rhythm relationships are evaluated
# after realization; one attack cannot eliminate an otherwise useful tune.
broad = abs(len(rhythm.slots) - expected) > max(4, expected / 2)
return broad, generator.solo.policy.stable_rank(plan.seed, rhythm.id)
def family(rhythm):
pitches = tuple(a.pitch.number() for a in rhythm.anchors)
shape = generator.solo.sequences.family(tuple(b - a for a, b in zip(pitches, pitches[1:])))
return rhythm.relationship, rhythm.groups[0].source, shape
limit = generator.solo.policy.DEFAULT.draft_frontier
return generator.solo.sequences.diverse(result, rank, family, limit * (batch + 1))[limit * batch:]
def canonical_context(notes, incoming, plan):
prefix = incoming.notes if incoming.passage_id == plan.passage_id and plan.passage_id else ((incoming.last,) if incoming.last else ())
return generator.solo.types.sounding_notes((*prefix, *notes))
def local_sounding(notes):
if notes and notes[0].continuation:
notes = (dataclasses.replace(notes[0], continuation=False), *notes[1:])
return generator.solo.types.sounding_notes(notes)
def contract_valid(draft, notes):
by_id = {n.event_id: n for n in local_sounding(notes)}
for tail in draft.sustained_tails:
if not any(n.pitch.number() == tail.pitch and n.start <= tail.start and n.end >= tail.end for n in by_id.values()):
return False, f'{tail.start}-{tail.end}: required sounding arrival tail changed'
original = {n.event_id: n for n in draft.notes}
for identity in (*draft.structural, *draft.protected):
before, after = original[identity], by_id.get(identity)
if after is None or (before.start, before.end, before.pitch) != (after.start, after.end, after.pitch):
return False, f'{identity}: protected draft event changed'
for transform in draft.transformations:
if not transform.preserves:
continue
core = [by_id.get(identity) for identity in transform.events[:min(3, len(transform.events))]]
if not all(core):
return False, f'{transform.id}: identifying events missing'
if 'rhythm' in transform.preserves and tuple((n.start - transform.start, n.end - transform.start) for n in core) != transform.rhythm[:len(core)]:
return False, f'{transform.id}: identifying rhythm changed'
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(core, core[1:]))
if 'opening_contour' in transform.preserves and directions != transform.directions[:len(directions)]:
return False, f'{transform.id}: identifying contour changed'
for feature in draft.features:
if generator.solo.features.feature_error(feature, local_sounding(notes)):
return False, f'{feature.id}: selected identifying relationship changed'
for first, repeated in draft.correspondence:
if first not in by_id or repeated not in by_id:
return False, 'lead statement/return correspondence was lost'
return True, ''
def relationship_features(draft, notes):
"""Inspect relationships once, on canonical events, independent of cells."""
notes = local_sounding(notes)
by_id = {n.event_id: n for n in notes}
preservation = sum(identity not in by_id for identity in draft.structural) / max(1, len(draft.structural))
direction, development = [], []
for transform in draft.transformations:
group = [by_id[i] for i in transform.events if i in by_id]
actual = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(group, group[1:]))
direction.extend(a != b for a, b in zip(actual, transform.directions))
reference = draft.reference_motif if transform.operation in ('statement', 'contrast') else draft.home
if reference and not draft.lead_identity and not draft.source_phrase and transform.operation not in ('close', 'fragment', 'broaden_ending'):
development.append(generator.solo.features.relationship(group, reference))
joins = []
for time in draft.joins:
left = next((n for n in reversed(notes) if n.start < time), None)
right = next((n for n in notes if n.start >= time), None)
if left and right:
original_left = next((n for n in reversed(draft.notes) if n.start < time), None)
original_right = next((n for n in draft.notes if n.start >= time), None)
planned_gap = max(F(), original_right.start - original_left.end) if original_left and original_right else F()
gap = max(F(), right.start - left.end - planned_gap)
joins.append(min(1, float(gap) / 2) + min(1, max(0, abs(right.pitch.number() - left.pitch.number()) - 4) / 12))
terminal = next((a for a in draft.anchors if a.role == 'terminal' and a.earliest < draft.end), None)
integration = 0.0
if terminal:
arrival = generator.solo.harmonic.anchor_note(terminal, notes)
before = next((n for n in reversed(notes) if arrival and n.start < arrival.start), None)
integration = min(1, max(0, abs(arrival.pitch.number() - before.pitch.number()) - 4) / 12) if arrival and before else 0
return {'draft_preservation': preservation, 'development': sum(development) / max(1, len(development)), 'direction': sum(direction) / max(1, len(direction)), 'join_coherence': sum(joins) / max(1, len(joins)), 'ending_integration': integration}
-204
View File
@@ -1,204 +0,0 @@
"""Bounded target-driven ornaments after strict thematic pair selection.
The main tune and its home identity remain strictly mapped. Every exception is
an atomic upper/lower/target gesture, never a replacement policy for single notes.
"""
from __future__ import annotations
import dataclasses
import math
import time
from fractions import Fraction as F
import generator.model
from generator.solo import types, harmonic, placement, policy, drafts, routes, phrasing
def mapped_pitch(number, start, end, harmony):
if number%12 not in types.permitted(harmony.choices,start,end):return None
span=harmony.at(start)
return types.scale_pitch(span.scale,types.scale_index(span.scale,number))
def approach(target, harmony):
"""Spelling follows the sounding collection, or explicitly spells resolution."""
t=target.start; number=target.pitch.number()
upper=next((p for n in range(number+1,number+4) if (p:=mapped_pitch(n,t-1,t-F(1,2),harmony))),None)
if upper is None:return None
lower=mapped_pitch(number-1,t-F(1,2),t,harmony)
if lower is None:
if target.pitch.alter>0:
lower=dataclasses.replace(target.pitch,alter=target.pitch.alter-1)
else:
letters='cdefgab'; index=letters.index(target.pitch.letter)
letter=letters[(index-1)%7]; octave=target.pitch.octave-(index==0)
alter=number-1-generator.model.NATURAL[letter]-12*(octave+1)
lower=types.Pitch(letter,F(alter),octave)
if not all(placement.positions(p.number(),harmony.profile) for p in (upper,lower)):return None
identity=target.event_id+':enclosure'
return (types.Note(t-1,t-F(1,2),upper,target.sources,'enclosure_upper',event_id=identity+':upper'),
types.Note(t-F(1,2),t,lower,target.sources,'enclosure_lower',event_id=identity+':lower'))
def declared_targets(phrase,harmony):
"""Declare eligible existing stops before proposing their ornaments."""
if not phrase.draft:return ()
terminal={a.pitch.number():a for a in phrase.anchors if a.role=='terminal'}
values=[]
held=phrase.draft.notes[0].event_id if phrase.draft.notes and phrase.draft.notes[0].continuation else None
for note in drafts.local_sounding(phrase.draft.notes):
if note.event_id==held:continue
if note.continuation or note.role=='quote' or note.end-note.start<1:continue
if note.pitch.number()%12 not in harmony.members(note.start,note.end):continue
anchor=terminal.get(note.pitch.number())
role='terminal' if anchor and anchor.earliest<=note.start<=anchor.latest else 'intermediate'
rank=(role!='terminal',phrasing.destination_cost(note.pitch,note.start,note.end,harmony,terminal=role=='terminal'),-(note.end-note.start),note.start)
values.append((rank,note,role))
return tuple((n,role) for _,n,role in sorted(values)[:policy.DEFAULT.enclosure_targets])
def replace_window(notes,start,end,replacement):
"""Only rewrite continuously sounding material; preserve meaningful space."""
cursor=start;prefix=[];suffix=[]
for note in notes:
if note.end<=start:prefix.append(note);continue
if note.start>=end:suffix.append(note);continue
if note.start>cursor:return None
cursor=max(cursor,note.end)
if note.start<start:prefix.append(dataclasses.replace(note,end=start))
if note.end>end:return None # never clip a pre-existing target sustain
if cursor<end:return None
return (*prefix,*replacement,*suffix)
def editable(draft,start,end):
ids=set((*draft.structural,*draft.protected,*(e for f in draft.features for e in f.events)))
return not any(n.start<end and n.end>start and (n.event_id in ids or n.role=='quote') for n in draft.notes) and not any(t.start<end and t.end>start for t in draft.sustained_tails)
def movement(notes):
return sum(max(0,abs(b.pitch.number()-a.pitch.number())-3)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1)
def candidates(phrase,harmony,library,tempo,rejections):
draft=phrase.draft
if not draft:return ()
if draft.notes and draft.notes[0].continuation:
# This local budget does not reopen a committed preceding phrase. Never
# invent a new attack by detaching its already-sounding entrance.
rejections.append({'target':draft.notes[0].event_id,'reason':'cross-phrase sustain retained; no local rewrite of its context'})
return ()
notes=types.sounding_notes(draft.notes);result=[]
for target,role in declared_targets(phrase,harmony):
start=target.start-1
if start<phrase.plan.start or not editable(draft,start,target.start):
rejections.append({'target':target.event_id,'reason':'protected material or no editable approach window'});continue
pair=approach(target,harmony)
if not pair:
rejections.append({'target':target.event_id,'reason':'no mapped upper neighbor or playable approach'});continue
upper,lower=pair
contract=types.EnclosureContract(target.event_id+':enclosure',upper,lower,target,harmony.at(target.start).chord,role,
types.SustainedTail(target.start,target.end,target.pitch.number()))
proposals=[(start,pair,None)]
# Reopen up to two beats before the approach as a whole in-map route. Keep
# the incoming attack and fixed upper endpoint, not independently chosen steps.
for first in reversed(notes):
if not start-2<=first.start<start or first.end>start or not editable(draft,first.end,target.start):continue
templates=tuple(n for n in notes if first.start<=n.start<start)
if not templates or any(a.end!=b.start for a,b in zip(templates,templates[1:])) or templates[-1].end!=start:continue
for route in routes.candidates((*templates,upper),{upper.event_id},harmony,library,(41,76),'mapped approach into enclosure')[:policy.DEFAULT.enclosure_routes]:
proposals.append((first.start,(*route.notes,lower),route))
break
accepted=0
for begin,replacement,route in proposals[:1+policy.DEFAULT.enclosure_routes]:
actual=replace_window(notes,begin,target.start,replacement)
if actual is None:continue
variants=[(actual,contract)]
upper_index=next((i for i,n in enumerate(actual) if n.event_id==upper.event_id),0)
if upper_index:
prior=actual[upper_index-1]
if prior.end==upper.start and prior.pitch==upper.pitch and editable(draft,prior.start,upper.start):
shared=dataclasses.replace(prior,end=upper.end)
combined=(*actual[:upper_index-1],shared,*actual[upper_index+1:])
variants.append((combined,dataclasses.replace(contract,upper=shared,approach_start=upper.start)))
for actual,actual_contract in variants:
valid,error=drafts.contract_valid(draft,actual)
if not valid:continue
checked=harmony.evaluate(actual,phrase.state.treatments if phrase.state else (),until=phrase.plan.end,complete=True,enclosures=(actual_contract,))
if not checked.valid:continue
valid,error=harmonic.anchors_valid(phrase.anchors,actual,phrase.plan.end,complete=True,closure=phrase.plan.closure)
if not valid:continue
placed=placement.place(actual,tempo,profile=harmony.profile)
if placed is None:continue
old_travel=placement.place(notes,tempo,profile=harmony.profile)[0]
delta=.6*(movement(actual)-movement(notes))+.025*(placed[0]-old_travel)/max(1,len(notes))/2000
density=4*len(actual)/float(phrase.plan.end-phrase.plan.start)
before_density=4*len(notes)/float(phrase.plan.end-phrase.plan.start)
repeated=sum(a.pitch.number()==b.pitch.number() and a.end==b.start for a,b in zip(actual,actual[1:]))
old_repeated=sum(a.pitch.number()==b.pitch.number() and a.end==b.start for a,b in zip(notes,notes[1:]))
delta+=.15*max(0,repeated-old_repeated)
delta+=.1*(max(0,density-6)-max(0,before_density-6))
transforms=tuple(dataclasses.replace(t,events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end)) for t in draft.transformations)
changed=dataclasses.replace(draft,notes=actual,transformations=transforms,enclosures=(actual_contract,))
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in actual if d.start<=n.start<d.end)) for d in phrase.decisions)
if tuple(n for d in decisions for n in d.notes)!=actual:continue
retained=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
result.append((delta,dataclasses.replace(phrase,draft=changed,decisions=decisions,before_enclosure=draft,
routes=(*retained,*((route,) if route else ())))))
accepted+=1
if not accepted:rejections.append({'target':target.event_id,'reason':'no valid complete enclosure within local route budget'})
return tuple(result)
def strict(solo):
"""Recover the exact selected pre-enclosure notes, including their placement."""
phrases=[]
for phrase in solo.phrases:
draft=phrase.before_enclosure
if not draft:phrases.append(phrase);continue
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in draft.notes if d.start<=n.start<d.end)) for d in phrase.decisions)
state=dataclasses.replace(phrase.state,notes=draft.notes,last=draft.notes[-1] if draft.notes else phrase.state.last) if phrase.state else None
phrases.append(dataclasses.replace(phrase,draft=draft,decisions=decisions,state=state))
return dataclasses.replace(solo,phrases=tuple(phrases),diagnostics={**solo.diagnostics,'enclosures_enabled':False})
def refine(solo,library,enabled=True):
started=time.monotonic();harmony=harmonic.Harmony(solo.choices,solo.profile);rejections=[]
baseline=[]
for phrase in solo.phrases:
draft=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes)) if phrase.draft else None
baseline.append(dataclasses.replace(phrase,draft=draft,before_enclosure=draft))
baseline=tuple(baseline)
if not enabled:
return dataclasses.replace(solo,phrases=baseline,diagnostics={**solo.diagnostics,'enclosures_enabled':False,'enclosure_seconds':time.monotonic()-started})
maximum=math.ceil(policy.DEFAULT.enclosure_max_per_32_bars*float(solo.end)/128)
low,high=2*float(solo.end)/128,3*float(solo.end)/128
states=[(0.,(),())];options_count=0
def sparse(count):return .10*max(0,low-count)+.12*max(0,count-high)
for phrase in baseline:
options=((0.,phrase),*candidates(phrase,harmony,library,solo.tempo,rejections));options_count+=len(options)-1
following=[]
for cost,chosen,arrivals in states:
for delta,option in options:
enclosure=option.draft.enclosures[0] if option.draft and option.draft.enclosures else None
if enclosure and len(arrivals)>=maximum:continue
spacing=.08*max(0,1-float(enclosure.target.start-arrivals[-1][0])/16) if enclosure and arrivals else 0.
repeated=.06*sum(pc==enclosure.target.pitch.number()%12 for _,pc in arrivals) if enclosure else 0.
history=(*arrivals,(enclosure.target.start,enclosure.target.pitch.number()%12)) if enclosure else arrivals
following.append((cost+delta+spacing+repeated,(*chosen,option),history))
states=sorted(following,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2])))[:policy.DEFAULT.enclosure_frontier]
_,phrases,arrivals=min(states,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2])))
# Resolve the complete guitar path once, including both sides of phrase joins.
raw=tuple(n for p in phrases for d in p.decisions for n in d.notes)
placed=placement.place(raw,solo.tempo,profile=harmony.profile)
if placed is None:raise ValueError('selected enclosure solo has no complete guitar path')
by_id={n.event_id:n for n in placed[1]};final=[]
for phrase in phrases:
decisions=tuple(dataclasses.replace(d,notes=tuple(by_id[n.event_id] for n in d.notes)) for d in phrase.decisions)
notes=tuple(n for d in decisions for n in d.notes)
state=dataclasses.replace(phrase.state,notes=notes,last=notes[-1] if notes else phrase.state.last) if phrase.state else None
final.append(dataclasses.replace(phrase,decisions=decisions,draft=dataclasses.replace(phrase.draft,notes=notes) if phrase.draft else None,state=state))
diagnostics={**solo.diagnostics,'enclosures_enabled':True,'enclosure_seconds':time.monotonic()-started,
'enclosure_candidates':options_count,'enclosure_rejections':rejections,'enclosures_selected':len(arrivals)}
return dataclasses.replace(solo,phrases=tuple(final),diagnostics=diagnostics)
+1 -175
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import dataclasses
from fractions import Fraction as F
from generator import harmony as chord_model, model, pitch as spelling, vocabulary
from generator.solo import types, drafts, enclosures, placement, policy
from generator.solo import types
def quality(chord):
@@ -158,177 +158,3 @@ def validate_frontier(notes,contracts,harmony,until,complete):
if incomplete:pending.append(c)
approved,error=validate(tuple(sorted(future.values(),key=lambda n:(n.start,n.event_id))),contracts,harmony)
return {key:c for key,c in approved.items() if key in actual},tuple(pending),error
def proposals(phrase,harmony):
draft=phrase.draft
if not draft or any(n.continuation for n in draft.notes):return
notes=types.sounding_notes(draft.notes)
for target,role in enclosures.declared_targets(phrase,harmony):
for rule in vocabulary.data('lead_ins')['patterns']:
start=target.start-F(rule['gap'])-sum((F(d) for d in rule['durations']),F())
if start<phrase.plan.start or not enclosures.editable(draft,start,target.start):continue
cursor=start;events=[]
for i,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])):
events.append(types.Note(cursor,cursor+F(duration),chromatic(target.pitch,step),target.sources,rule['kind'],event_id=target.event_id+':'+rule['id']+':'+str(i)))
cursor+=F(duration)
actual=enclosures.replace_window(notes,start,target.start,tuple(events))
if actual is None:continue
prior=next((n for n in reversed(actual) if n.end<=start),None)
contract=types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(events),target,harmony.at(target.start).chord,preparation=prior)
yield actual,contract
for i,n in enumerate(notes[:-1]):
target=notes[i+1]
if not enclosures.editable(draft,n.start,n.end):continue
span=harmony.at(n.start)
if not span or not span.scale:continue
for rule in vocabulary.data('harmony')['colors']:
if not eligible_color(rule,span,harmony) or n.end-n.start>F(rule['max_beats']):continue
p=degree_pitch(chord_model.parse_symbol(span.chord),rule['degree'],rule['alter'],n.pitch.number())
if abs(p.number()-n.pitch.number())>4 or p==n.pitch:continue
color=dataclasses.replace(n,pitch=p,role='color')
resolution=target if rule.get('resolve') else color
contract=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(color,),resolution,harmony.at(resolution.start).chord)
yield (*notes[:i],color,*notes[i+1:]),contract
for rule in vocabulary.data('lead_ins')['blues']:
if i==0 or n.end-n.start>F(1,2):continue
p=degree_pitch(chord_model.parse_symbol(span.chord),rule['degree'],rule['alter'],n.pitch.number())
changed=dataclasses.replace(n,pitch=p,role='blues')
yield (*notes[:i],changed,*notes[i+1:]),types.ExpressionContract(n.event_id+':'+rule['id'],'blues',rule['id'],(changed,),target,harmony.at(target.start).chord,preparation=notes[i-1])
for i,target in enumerate(notes):
if i==0 or target.start not in {s.start for s in harmony.spans}:continue
start=target.start-F(1,2)
if not enclosures.editable(draft,start,target.start):continue
n=dataclasses.replace(target,start=start,role='anticipation')
actual=enclosures.replace_window(notes,start,target.start,())
if actual:
actual=tuple(n if a.event_id==target.event_id else a for a in actual)
yield actual,types.ExpressionContract(target.event_id+':anticipate','anticipation','early_chord_tone',(n,),n,harmony.at(target.start).chord,boundary=target.start)
for i,(before,target) in enumerate(zip(notes,notes[1:])):
boundary=target.start
if before.end!=boundary or boundary not in {s.start for s in harmony.spans}:continue
if not enclosures.editable(draft,before.start,target.end):continue
for rule in vocabulary.data('harmony').get('suspensions',[]):
delay=min(F(1,2),F(rule['max_delay']))
if target.end<=boundary+delay:continue
held=dataclasses.replace(before,end=boundary+delay,role='suspension')
resolved=dataclasses.replace(target,start=boundary+delay)
yield (*notes[:i],held,resolved,*notes[i+2:]),types.ExpressionContract(before.event_id+':suspend',
'suspension',rule['id'],(held,),resolved,harmony.at(boundary).chord,boundary=boundary)
def joint_candidates(phrases,incoming,harmony,tempo,context,*,bar=F(4)):
"""Expression alternatives compete BEFORE complete-phrase selection.
This rewrites provisional gestures, never the selected chorus. The direct
realization remains available and every alternative uses the same evaluator.
"""
from generator.solo import realization, sequences, phrasing, groups
result=list(phrases)
trace=context.diagnostics.setdefault('expression_operations',{})
for phrase in phrases:
proposed={}
for actual,c in proposals(phrase,harmony):
counts=trace.setdefault(c.kind,dict(proposed=0,valid=0,retained=0,rejected_by_rule=0))
counts['proposed']+=1
old_pattern=tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in phrase.draft.notes)
new_pattern=tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in actual)
if groups.valid(old_pattern) and not groups.valid(new_pattern):
counts['rejected_by_rule']+=1;continue
if not drafts.contract_valid(phrase.draft,actual)[0]:
counts['rejected_by_rule']+=1;continue
checked=harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(*phrase.draft.expressions,c))
if not checked.valid:
counts['rejected_by_rule']+=1;continue
counts['valid']+=1
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
join=abs(prior.pitch.number()-c.events[0].pitch.number()) if prior else 0
rank=max(0,join-5)+bool(prior and prior.pitch==c.events[0].pitch)
proposed.setdefault(c.kind,[]).append((rank,actual,c))
# One representative per treatment kind, then a bounded second sweep.
options=sequences.diverse([v for values in proposed.values() for v in values],
lambda v:(v[0],policy.stable_rank(phrase.plan.seed,v[2].id)),lambda v:v[2].kind,8)
for _,actual,c in options:
transforms=tuple(dataclasses.replace(t,
events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end))
for t in phrase.draft.transformations)
changed=dataclasses.replace(phrase.draft,id=phrase.draft.id+':'+c.rule,
notes=actual,expressions=(*phrase.draft.expressions,c),transformations=transforms)
candidate=realization.checked_phrase(phrase.plan,changed,actual,incoming.treatments,incoming,harmony,tempo,phrase.edits)
if candidate is None:continue
trace[c.kind]['retained']+=1
state=dataclasses.replace(phrase.state,notes=candidate.state.notes,last=candidate.state.last,
motifs=candidate.state.motifs,travel=candidate.state.travel)
history=incoming.history
for t in transforms:
ns=tuple(n for n in actual if n.event_id in t.events)
if ns:history=phrasing.remember(history,phrasing.heard(ns,t.operation,bar=bar),bar)
state=dataclasses.replace(state,history=history)
kept_routes=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==
(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
original=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes))
result.append(dataclasses.replace(candidate,state=state,initial=phrase.initial,
refined=phrase.refined,before_rhythm=phrase.before_rhythm,before_enclosure=original,routes=kept_routes))
return result
def refine(solo,enabled=True):
from generator.solo import harmonic
harmony=harmonic.Harmony(solo.choices,solo.profile);result=[];history=[];attempts=0
seed=vocabulary.role(solo.part+'_solo').get('choice_seed',solo.part)
for phrase in solo.phrases:
draft=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes))
phrase=dataclasses.replace(phrase,draft=draft,before_enclosure=draft)
options=[(0.,phrase,None)]
if enabled:
original=draft.notes
for actual,c in proposals(phrase,harmony):
attempts+=1
if not drafts.contract_valid(draft,actual)[0]:continue
if not harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(c,)).valid:continue
if not harmonic.anchors_valid(phrase.anchors,actual,phrase.plan.end,complete=True,closure=phrase.plan.closure)[0]:continue
placed=placement.place(actual,solo.tempo,profile=solo.profile)
if not placed:continue
# Context, repetition, and motion determine utility; no chromatic count target.
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
join=abs(prior.pitch.number()-c.events[0].pitch.number()) if prior else 0
duplicated=bool(prior and prior.pitch==c.events[0].pitch)
destination=c.target.pitch.number()
old_approach=next((n for n in reversed(original) if n.end<=c.target.start),None)
prepares=bool(old_approach and abs(old_approach.pitch.number()-destination)>2 and join<=3)
recent=sum(kind==c.kind for kind in history[-2:])
cost=.08*max(0,join-3)+.18*duplicated+.12*recent+.3*(enclosures.movement(actual)-enclosures.movement(original))-.12*prepares
if c.kind=='anticipation':cost-=.05
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in actual if d.start<=n.start<d.end)) for d in phrase.decisions)
if tuple(n for d in decisions for n in d.notes)!=actual:continue
# Rebase editable development spans onto the completed gesture. The
# feature contract is unchanged, but stale event IDs/rhythm would make
# reports falsely describe notes that the expression replaced.
transforms=tuple(dataclasses.replace(t,
events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end))
for t in draft.transformations)
changed=dataclasses.replace(draft,notes=actual,expressions=(*phrase.draft.expressions,c),transformations=transforms)
routes=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
options.append((cost,dataclasses.replace(phrase,draft=changed,decisions=decisions,routes=routes),c))
_,chosen,c=min(options,key=lambda v:(round(v[0],8),policy.stable_rank(seed,(phrase.plan.id,v[2]))))
if c:history.append(c.kind)
result.append(chosen)
raw=tuple(n for p in result for d in p.decisions for n in d.notes)
placed=placement.place(raw,solo.tempo,profile=solo.profile)
if raw and placed is None:raise ValueError('Expression realization has no complete guitar path')
by_id={n.event_id:n for n in placed[1]} if placed else {}
final=[]
for p in result:
decisions=tuple(dataclasses.replace(d,notes=tuple(by_id[n.event_id] for n in d.notes)) for d in p.decisions)
notes=tuple(n for d in decisions for n in d.notes)
state=dataclasses.replace(p.state,notes=notes,last=notes[-1] if notes else p.state.last) if p.state else None
# A legal timing revision preserves the complete route with its new times.
routes=[]
for route in p.routes:
if all(n.event_id in by_id and by_id[n.event_id].pitch==n.pitch for n in route.notes):
route_notes=tuple(by_id[n.event_id] for n in route.notes)
routes.append(dataclasses.replace(route,notes=route_notes,destination=route_notes[-1]))
final.append(dataclasses.replace(p,decisions=decisions,draft=dataclasses.replace(p.draft,notes=notes),state=state,routes=tuple(routes)))
return dataclasses.replace(solo,phrases=tuple(final),diagnostics={**solo.diagnostics,'expression_candidates':attempts,'expressions_selected':history,'enclosures_enabled':enabled})
-56
View File
@@ -1,56 +0,0 @@
"""Pure musical contracts; no reference files or filesystem access."""
from __future__ import annotations
import dataclasses
import fractions
import generator.solo.types as types
F = fractions.Fraction
def identifying_features(notes, source):
"""Select a characteristic leap followed by return, wherever it occurs."""
notes = tuple(notes)
candidates = []
for i in range(len(notes) - 2):
a, b, c = notes[i:i + 3]
leap, reply = b.pitch.number() - a.pitch.number(), c.pitch.number() - b.pitch.number()
if 3 <= abs(leap) <= 9 and leap * reply < 0 and c.start - a.start <= 4:
group = notes[i:min(len(notes), i + 4)]
rhythm = tuple((n.start - a.start, n.end - n.start) for n in group)
intervals = tuple(y.pitch.number() - x.pitch.number() for x, y in zip(group, group[1:]))
candidates.append(types.IdentifyingFeature(a.event_id + '_identity', tuple(n.event_id for n in group), rhythm, intervals, source))
return tuple(candidates[:1])
def feature_error(feature, notes):
by_id = {n.event_id: n for n in notes}
actual = [by_id.get(identity) for identity in feature.events]
if not actual or not all(actual):
return 1.0
first = actual[0].start
rhythm = tuple((n.start - first, n.end - n.start) for n in actual)
movement = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:]))
errors = []
if 'rhythm' in feature.preserves:
errors.append(1.0 if len(rhythm) != len(feature.rhythm) else sum(a != b for a,b in zip(rhythm,feature.rhythm))/max(1,len(rhythm)))
if 'intervals' in feature.preserves or 'contour' in feature.preserves:
if len(movement) != len(feature.intervals) or not movement and feature.intervals:
errors.append(1.0)
else:
errors.append(sum(((a>0)-(a<0)) != ((b>0)-(b<0)) or 'intervals' in feature.preserves and abs(a-b)>2 for a,b in zip(movement,feature.intervals))/max(1,len(movement)))
if 'exact_intervals' in feature.preserves:
errors.append(float(movement != feature.intervals))
if 'pitches' in feature.preserves:
errors.append(float(tuple(n.pitch.number() for n in actual) != feature.pitches))
return max(errors,default=0.0)
def relationship(notes, reference):
"""One joint relationship cost: timed movement, not absolute-pitch copying."""
notes = tuple(notes)
if len(notes) < 2 or len(reference.notes) < 2:
return 0.0 if len(notes) == len(reference.notes) == 1 else 1.0
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
expected = reference.contour()
count = min(len(actual), len(expected))
contour = sum(0.75 * (((a > 0) - (a < 0)) != ((b > 0) - (b < 0))) + 0.25 * min(1, max(0, abs(a - b) - 2) / 7) for a, b in zip(actual, expected)) / max(1, count)
start, ref_start = notes[0].start, reference.notes[0].start
rhythm = sum(min(1, float(abs((n.start - start) - (r.start - ref_start))) / 2) for n, r in zip(notes, reference.notes)) / min(len(notes), len(reference.notes))
evidence = max(0, min(3, len(reference.notes)) - len(notes)) / 3
return max(evidence, (contour + rhythm) / 2)
-129
View File
@@ -1,129 +0,0 @@
"""Bounded timing revisions of complete realized gestures, with fixed pitches."""
from __future__ import annotations
import dataclasses
from fractions import Fraction as F
from generator.solo import drafts, harmonic, types, groups
def join_variants(draft,harmony,library):
"""Recompose both sides of an editable internal join as one complete route.
Keep endpoints and exact span. A provisional held tail can reopen into two
binary attacks; a committed hold or identifying event is never consumed.
Alternatives are returned to the authoritative phrase evaluator.
"""
from generator.solo import routes, policy
fixed=set(draft.protected)|set(draft.structural)|{e for f in draft.features for e in f.events}
fixed.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
produced=0
for join in (t.start for t in draft.transformations[1:]):
inside=tuple(n for n in draft.notes if join-2<=n.start and n.end<=join+2)
if len(inside)<3 or any(n.event_id in fixed or n.role=='quote' for n in inside[1:-1]):continue
if any(t.start<inside[-1].end and t.end>inside[0].start for t in draft.sustained_tails):continue
pattern=tuple((n.start-inside[0].start,n.end-inside[0].start) for n in inside)
if not groups.valid(pattern):continue
alternatives=[inside]
for i,n in enumerate(inside[1:-1],1):
if n.end==join and n.end-n.start>=1 and (n.start*2).denominator==1:
cut=n.end-F(1,2)
alternatives.append((*inside[:i],dataclasses.replace(n,end=cut),
dataclasses.replace(n,start=cut,event_id=n.event_id+':extend'),*inside[i+1:]))
break
for template in alternatives:
for route in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'two-sided join'):
if route.notes==inside:continue
actual=tuple(n for n in draft.notes if n.end<=inside[0].start)+route.notes+tuple(n for n in draft.notes if n.start>=inside[-1].end)
if not drafts.contract_valid(draft,actual)[0] or not harmony.evaluate(actual,until=draft.end,complete=True,expressions=draft.expressions).valid:continue
transforms=tuple(dataclasses.replace(t,events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end)) for t in draft.transformations)
changed=dataclasses.replace(draft,id=draft.id+':join:'+route.id,notes=actual,transformations=transforms)
yield changed,types.DraftEdit('two_sided_join',inside[0].start,inside[-1].end,
tuple(n.event_id for n in inside),tuple(n.event_id for n in route.notes),'complete route over both sides; endpoints and contracts preserved'),route
produced+=1
if produced>=policy.DEFAULT.join_alternatives:return
def fragmentation(notes):
isolated = sum(n.end-n.start <= F(1,2) and a.end < n.start and n.end < b.start
for a,n,b in zip(notes, notes[1:], notes[2:]))
gaps = sum(0 < b.start-a.end <= 1 for a,b in zip(notes, notes[1:]))
return isolated + .1*gaps
def shared_arrival(draft,harmony,treatments=()):
"""A provisional endpoint may also begin a continuation, without reattack.
Retain the continuation event's identity. A rhythm/exact-quote contract or
a committed destination can forbid sharing; full context is revalidated.
"""
notes=draft.notes
joins={t.start for t in draft.transformations[1:] if t.operation=='continue'}
fixed=set(draft.protected)|set(draft.structural)
fixed.update(n.event_id for n in notes if n.role=='quote')
fixed.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
fixed.update(e for f in draft.features if 'rhythm' in f.preserves for e in f.events)
edits=[]
for join in sorted(joins):
for i,(a,b) in enumerate(zip(notes,notes[1:])):
if b.start!=join or a.end!=b.start or a.pitch!=b.pitch:continue
if a.event_id in fixed or b.event_id in fixed:continue
actual=(*notes[:i],dataclasses.replace(b,start=a.start),*notes[i+2:])
if not drafts.contract_valid(draft,actual)[0]:continue
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True,expressions=draft.expressions).valid:continue
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]:continue
edits.append(types.DraftEdit('shared_arrival',a.start,b.end,(a.event_id,b.event_id),(b.event_id,),
'reopen provisional endpoint as the same sounding continuation; preserve feature and arrival contracts'))
notes=actual
break
if not edits:return draft,()
by_id={n.event_id:n for n in notes}
transforms=[]
for t in draft.transformations:
group=tuple(by_id[e] for e in t.events if e in by_id)
if group:
transforms.append(dataclasses.replace(t,start=group[0].start,end=group[-1].end,
events=tuple(n.event_id for n in group),rhythm=tuple((n.start-group[0].start,n.end-group[0].start) for n in group)))
return dataclasses.replace(draft,notes=notes,transformations=tuple(transforms)),tuple(edits)
def revise(draft, harmony, treatments=()):
draft,shared=shared_arrival(draft,harmony,treatments)
notes=draft.notes; edits=list(shared)
if len(notes)<3: return draft, tuple(edits)
protected=set(draft.protected)|set(draft.structural)|{n.event_id for n in notes if n.role=='quote'}
# An identifying rhythm also protects its internal rests.
protected.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
protected.update(e for feature in draft.features if 'rhythm' in feature.preserves for e in feature.events)
for _ in range(2):
proposals=[]
for i,(a,n,b) in enumerate(zip(notes,notes[1:],notes[2:]),1):
if n.event_id in protected or n.end-n.start>1: continue
if not (a.end<n.start or n.end<b.start): continue
if a.event_id not in protected and 0<n.start-a.end<=1:
proposals.append((*notes[:i-1],dataclasses.replace(a,end=n.start),*notes[i:]))
if 0<b.start-n.end<=1:
proposals.append((*notes[:i],dataclasses.replace(n,end=b.start),*notes[i+1:]))
for delta in (F(-1,2),F(1,2)):
if a.end<=n.start+delta and n.end+delta<=b.start:
proposals.append((*notes[:i],dataclasses.replace(n,start=n.start+delta,end=n.end+delta),*notes[i+1:]))
if len(proposals)>=8: break
valid=[]
for actual in proposals[:8]:
if not groups.valid(tuple((n.start-draft.start,n.end-draft.start) for n in actual)):continue
if fragmentation(actual)>=fragmentation(notes): continue
if not drafts.contract_valid(draft,actual)[0]: continue
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True,expressions=draft.expressions).valid: continue
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]: continue
valid.append(actual)
if not valid: break
actual=min(valid,key=lambda ns:(fragmentation(ns),tuple((n.start,n.end,n.pitch.number()) for n in ns)))
changed=[(a,b) for a,b in zip(notes,actual) if (a.start,a.end)!=(b.start,b.end)]
edits.append(types.DraftEdit('connected_rhythm',min(min(a.start,b.start) for a,b in changed),
max(max(a.end,b.end) for a,b in changed),tuple(a.event_id for a,b in changed),
tuple(b.event_id for a,b in changed),'same pitch sequence and attack count; connect an editable gesture'))
notes=actual
transforms=tuple(dataclasses.replace(t,rhythm=tuple((n.start-t.start,n.end-t.start) for n in notes if n.event_id in t.events))
if not t.preserves else t for t in draft.transformations)
return dataclasses.replace(draft,notes=notes,transformations=transforms),tuple(edits)
-149
View File
@@ -1,149 +0,0 @@
"""Joint rhythmic and harmonic actions, constructed before pitch-route search.
Rules describe musical relationships, never reference-score phrases. Contracts
are bound to the selected whole route and validated before a candidate survives.
"""
from __future__ import annotations
import dataclasses
from fractions import Fraction as F
from generator import vocabulary, harmony as chords
from generator.solo import types, expressions, groups, sequences, policy
@dataclasses.dataclass(frozen=True)
class Proposal:
notes: tuple
contracts: tuple
purpose: str
def bind(self, notes):
actual={n.event_id:n for n in notes}
return tuple(dataclasses.replace(c,events=tuple(actual[n.event_id] for n in c.events),
target=actual[c.target.event_id],preparation=actual[c.preparation.event_id] if c.preparation else None) for c in self.contracts)
def replace(notes, start, end, events):
# Provisional rhythm is editable, but a sliced tuplet is not an explanation.
before=tuple(dataclasses.replace(n,end=start) if n.end>start else n for n in notes if n.start<start)
after=tuple(dataclasses.replace(n,start=end,event_id=n.event_id+':resumed') if n.start<end else n for n in notes if n.end>end)
actual=(*before,*events,*after)
if any(n.end<=n.start for n in actual):return ()
origin=notes[0].start
if not groups.valid(tuple((n.start-origin,n.end-origin) for n in actual)):return ()
return actual
def proposals(notes,harmony,start,finish,ending=None):
"""Plan future arrivals, colors and approaches together with exact rhythm."""
rules=vocabulary.data('melodic_vocabulary')['gesture_rules']
fixed_end=ending.earliest if ending else finish
for rule in rules:
if rule['kind']=='harmonic_arrival':
for span in harmony.spans:
boundary=span.start
prior=harmony.at(boundary-F(1,100))
if not span.scale or not prior or not prior.scale or prior.chord==span.chord:continue
if not start+1<boundary<fixed_end:continue
for offset in map(F,rule['arrival_offsets']):
arrival=boundary+offset
if arrival<start+1:continue
for hold in map(F,rule['holds']):
end=arrival+hold
if end>min(fixed_end,span.end) or end<=boundary:continue
breath=min(F(rule['release']),fixed_end-end)
near=next((n.pitch.number() for n in notes if n.start<=arrival<n.end),notes[-1].pitch.number())
members=harmony.members(boundary if offset<0 else arrival,end)&types.permitted(harmony.choices,boundary if offset<0 else arrival,end)
candidates=tuple(types.scale_pitch(span.scale,i) for i in range(-21,29))
candidates=tuple(p for p in candidates if p.number()%12 in members and harmony.profile.permits(p.number()))
# Third/seventh, root/fifth and register alternatives remain real
# competitors; no target name or song position determines a winner.
targets=sequences.diverse(candidates,lambda p:(abs(p.number()-near),p.number()),
lambda p:next((d for pc,d in span.roles.degrees if pc==p.number()%12),()),3)
for pitch in targets:
target=types.Note(arrival,end,pitch,notes[0].sources,'arrival',event_id=notes[0].event_id+f':{rule["id"]}:{arrival}:{pitch.number()}')
actual=replace(notes,arrival,end+breath,(target,))
if len(actual)<2:continue
contracts=()
if offset<0:
contracts=(types.ExpressionContract(target.event_id,'anticipation','early_chord_tone',(target,),target,span.chord,boundary=boundary),)
yield Proposal(actual,contracts,rule['id']+':'+str(offset))
elif rule['kind']=='sustained_color':
for i,n in enumerate(notes):
if n.start>=fixed_end or n.end-n.start<1:continue
span=harmony.at(n.start)
if not span or not span.scale:continue
for color in vocabulary.data('harmony')['colors']:
if n.end-n.start>F(color['max_beats']) or not expressions.eligible_color(color,span,harmony):continue
pitch=expressions.degree_pitch(chords.parse_symbol(span.chord),color['degree'],color['alter'],n.pitch.number())
if not harmony.profile.permits(pitch.number()) or pitch==n.pitch:continue
changed=dataclasses.replace(n,pitch=pitch,role='color')
actual=(*notes[:i],changed,*notes[i+1:]);target=changed
if color.get('resolve'):
if i+1==len(notes):continue
next_note=notes[i+1];future=harmony.at(next_note.start)
if not future or not future.scale:continue
targets=[types.scale_pitch(future.scale,j) for j in range(-21,29)]
targets=[p for p in targets if 0<abs(p.number()-pitch.number())<=2 and p.number()%12 in harmony.members(next_note.start,next_note.end) and harmony.profile.permits(p.number())]
if not targets:continue
target=dataclasses.replace(next_note,pitch=min(targets,key=lambda p:(abs(p.number()-next_note.pitch.number()),p.number())))
actual=(*notes[:i],changed,target,*notes[i+2:])
c=types.ExpressionContract(changed.event_id+':'+color['id'],'color',color['id'],(changed,),target,span.chord)
yield Proposal(actual,(c,),rule['id'])
def approach(proposal,harmony,start,ending=None):
"""An approach may share its target with an anticipated destination."""
for i,target in enumerate(proposal.notes):
if target.end-target.start<1:continue
anticipated=next((c for c in proposal.contracts if c.kind=='anticipation' and c.target.event_id==target.event_id),None)
time=anticipated.boundary if anticipated else target.start
if target.pitch.number()%12 not in harmony.members(time,target.end):continue
span=harmony.at(time)
for rule in vocabulary.data('lead_ins')['patterns']:
arrival=target.start;cursor=arrival-F(rule['gap'])-sum(map(F,rule['durations']),F())
if cursor<start+F(1,2):continue
if ending and cursor>=ending.earliest:continue
events=[]
for j,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])):
events.append(types.Note(cursor,cursor+F(duration),expressions.chromatic(target.pitch,step),target.sources,rule['kind'],event_id=target.event_id+':'+rule['id']+':'+str(j)))
cursor+=F(duration)
actual=replace(proposal.notes,events[0].start,target.start,tuple(events))
if not actual:continue
if any(n.event_id not in {a.event_id for a in actual} for c in proposal.contracts for n in (*c.events,c.target)):continue
prior=next((n for n in reversed(actual) if n.end<=events[0].start),None)
c=types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(events),target,span.chord,
boundary=anticipated.boundary if anticipated else None,preparation=prior if rule['kind']=='connection' else None)
yield Proposal(actual,(*proposal.contracts,c),proposal.purpose+' + '+rule['kind'])
def select(notes,harmony,start,finish,ending=None,limit=4):
# Provenance differs across candidate histories; musical work does not.
# This cache belongs to one immutable Harmony and includes all timing,
# pitches, roles, continuation flags, profile and ending constraints.
canonical=tuple(dataclasses.replace(n,sources=(),event_id=f'@{i}@') for i,n in enumerate(notes))
key=(canonical,start,finish,ending,limit,harmony.profile)
if not hasattr(harmony,'gesture_cache'):harmony.gesture_cache={}
if key not in harmony.gesture_cache:
harmony.gesture_cache[key]=_select(canonical,harmony,start,finish,ending,limit)
if len(harmony.gesture_cache)>4096:harmony.gesture_cache.pop(next(iter(harmony.gesture_cache)))
def identity(value):
for i,n in enumerate(notes):value=value.replace(f'@{i}@',n.event_id)
return value
def restore(n):
i=int(n.event_id.split('@')[1])
return dataclasses.replace(n,event_id=identity(n.event_id),sources=notes[i].sources)
return tuple(Proposal(tuple(map(restore,p.notes)),tuple(dataclasses.replace(c,id=identity(c.id),
events=tuple(map(restore,c.events)),target=restore(c.target),
preparation=restore(c.preparation) if c.preparation else None) for c in p.contracts),p.purpose)
for p in harmony.gesture_cache[key])
def _select(notes,harmony,start,finish,ending=None,limit=4):
plain=Proposal(notes,(),'direct')
base=sequences.diverse(proposals(notes,harmony,start,finish,ending),
lambda p:(abs(len(p.notes)-len(notes)),tuple((n.start,n.end,n.pitch.number()) for n in p.notes)),
lambda p:(p.purpose,tuple(c.kind for c in p.contracts)),8)
values=(*base,*approach(plain,harmony,start,ending),*(p for b in base if b.purpose.startswith('harmonic') for p in approach(b,harmony,start,ending)))
# Complete structural choices compete, not one replacement at each attack.
return sequences.diverse(values,lambda p:(abs(len(p.notes)-len(notes)),policy.stable_rank('joint-purpose',tuple((n.start,n.end,n.pitch) for n in p.notes))),
lambda p:tuple(c.kind for c in p.contracts) or (p.purpose,),limit)
-451
View File
@@ -1,451 +0,0 @@
"""Source-phrase variations: edit actual music, then realize complete routes.
The external lead references are audition controls, never production seeds.
All notes use sounding coordinates and all time uses quarter-note Fractions.
"""
from __future__ import annotations
import dataclasses
import fractions
import functools
import generator.vocabulary
import generator.model
import generator.fretboard
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, features as references, profiles
F = fractions.Fraction
def signature(notes):
return tuple((n.start, n.end, n.pitch.number()) for n in notes)
def shift(notes, octave):
return tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave+octave),
position=None, role='source', event_id=n.event_id or n.sources[0]) for n in notes)
def available(harmony, start, end, *, members=False):
if not hasattr(harmony,'available_pitches'):harmony.available_pitches={}
span=harmony.at(start)
allowed=types.permitted(harmony.choices,start,end)
if members:allowed &= harmony.members(start,end)
if not span or not span.scale:return ()
key=(span.scale,allowed,harmony.profile)
if key not in harmony.available_pitches:
harmony.available_pitches[key]=tuple(p for i in range(-35,28)
if harmony.profile.register[0]<=(p:=types.scale_pitch(span.scale,i)).number()<=harmony.profile.register[1]
and p.number()%12 in allowed and placement.positions(p.number(),harmony.profile))
return harmony.available_pitches[key]
def adapt(notes, harmony):
"""Document required harmonic rewrites; never repair a sequence one step at a time."""
result, edits = [], []
for index, note in enumerate(notes):
terminal = index == len(notes)-1
choices = available(harmony,note.start,note.end,members=terminal or note.end-note.start >= 2)
pieces = [(note.start,note.end,choices)]
if not choices:
pieces = [(a,b,available(harmony,a,b,members=terminal or b-a >= 2)) for _,a,b in harmony.segments(note)]
for j,(start,end,domain) in enumerate(pieces):
if not domain:
if harmony.at(start) and harmony.at(start).scale:
return (), ()
edits.append(types.DraftEdit('missing_harmony',start,end,(note.event_id,),(), 'explicit no-chord space'))
continue
pitch = min(domain,key=lambda p:(abs(p.number()-note.pitch.number()),p.number()))
identifier = note.event_id if j == 0 else note.event_id+f':harmonic:{j}'
actual = dataclasses.replace(note,start=start,end=end,pitch=pitch,event_id=identifier)
result.append(actual)
if (start,end,pitch) != (note.start,note.end,note.pitch):
edits.append(types.DraftEdit('harmonic_adaptation',start,end,(note.event_id,),(identifier,),
f'{note.pitch.label()} to {pitch.label()}: sounding duration / {harmony.at(start).chord}'))
if result:
last=result[-1]
domain=available(harmony,last.start,last.end,members=True)
if not domain:
return (),()
if last.pitch.number()%12 not in harmony.members(last.start,last.end):
pitch=min(domain,key=lambda p:(abs(p.number()-last.pitch.number()),p.number()))
result[-1]=dataclasses.replace(last,pitch=pitch)
edits.append(types.DraftEdit('harmonic_destination',last.start,last.end,(last.event_id,),(last.event_id,),
f'{last.pitch.label()} to {pitch.label()}: final sounding arrival before release'))
return tuple(result),tuple(edits)
def identifying(notes, source):
"""A complete heard figure, chosen by its musical content rather than its index."""
if not notes:
return ()
count = min(4,max(1,len(notes)//2))
windows = [notes[i:i+count] for i in range(len(notes)-count+1)]
def rank(group):
intervals = tuple(b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:]))
rhythm = {(n.end-n.start) for n in group}
# Favor a rhythm/turn with information; leave long holds available for development.
return (-len(rhythm),-len(set(intervals)),sum(max(F(),n.end-n.start-1) for n in group),group[0].start)
group = min(windows,key=rank)
return (types.IdentifyingFeature(source+':figure',tuple(n.event_id for n in group),
tuple((n.start-group[0].start,n.end-n.start) for n in group),
tuple(b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])),source),)
def variations(notes, source, harmony):
"""One purposeful timing or pitch edit; no generic replacement openings."""
result=[]
if source.pickup and notes and source.start==0:
length=-source.pickup[0].start
if notes[0].end-length>=F(1,2):
pickup=tuple(dataclasses.replace(n,start=n.start+length,end=n.end+length) for n in source.pickup)
changed=(*pickup,dataclasses.replace(notes[0],start=length),*notes[1:])
if harmony.evaluate(changed,complete=True).valid:
result.append((changed,(types.DraftEdit('pickup_entrance',F(),notes[0].end,(notes[0].event_id,),
tuple(n.event_id for n in pickup)+(notes[0].event_id,),source.id+': bring source pickup inside the solo and shorten its first hold'),)))
for i,(before,note) in enumerate(zip(notes,notes[1:]),1):
if before.end == note.start and before.end-before.start >= 1 and note.end-note.start >= F(1,2):
for displacement in (F(-1,2),F(1,2)):
time = note.start+displacement
if time-before.start < F(1,2) or note.end-time < F(1,2):
continue
changed=(*notes[:i-1],dataclasses.replace(before,end=time),dataclasses.replace(note,start=time),*notes[i+1:])
if harmony.evaluate(changed,complete=True).valid:
edit=types.DraftEdit('rhythmic_paraphrase',before.start,note.end,(before.event_id,note.event_id),(before.event_id,note.event_id),
f'{source.id}: retain both pitches and their order; move shared boundary {displacement} beat')
result.append((changed,(edit,)))
# Structural color is chosen in relation to the original turn, never a new random contour.
if 0 < i < len(notes)-1:
after=notes[i+1]
for p in available(harmony,note.start,note.end,members=note.end-note.start>=1):
if p == note.pitch or abs(p.number()-note.pitch.number())>2:
continue
direction=lambda a,b:(b>a)-(b<a)
if (direction(before.pitch.number(),p.number()),direction(p.number(),after.pitch.number())) != (direction(before.pitch.number(),note.pitch.number()),direction(note.pitch.number(),after.pitch.number())):
continue
changed=(*notes[:i],dataclasses.replace(note,pitch=p),*notes[i+1:])
if harmony.evaluate(changed,complete=True).valid:
edit=types.DraftEdit('melodic_paraphrase',note.start,note.end,(note.event_id,),(note.event_id,),f'{source.id}: {note.pitch.label()} to {p.label()}, retain rhythm and turn')
result.append((changed,(edit,)))
# Broaden or release an ending as an explicit whole-phrase alternative.
if notes and notes[-1].end-notes[-1].start >= F(3,2):
tail=dataclasses.replace(notes[-1],end=notes[-1].end-F(1,2))
result.append(((*notes[:-1],tail),(types.DraftEdit('ending_release',tail.end,notes[-1].end,(tail.event_id,),(tail.event_id,),source.id+': half-beat breath after the arrival'),)))
return tuple(result)
def rhythm_catalog(library, meter):
# Keep complete snippet objects. Slices below retain real rests and durations.
return drafts.catalog(library,meter)
@functools.lru_cache(maxsize=256)
def connection_windows(catalog,length):
spans={( (F(),length), ):'held'}
for identifier,pattern,total in catalog:
if total < length:
continue
# Retrieve every exact-duration window, including offbeat and cross-bar windows.
for offset in sorted({F(),*(a for a,_ in pattern)}):
if offset+length>total:
continue
if any(a<offset<b or a<offset+length<b for a,b in pattern):
continue
selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
if 2<=len(selected)<=7 and selected[0][0]==0:
spans.setdefault(selected,identifier)
ranked=sequences.diverse(tuple(spans.items()),
lambda item:(abs(len(item[0])-float(length)*1.5),item[1]),
lambda item:(len(item[0]),any(a[1]!=b[0] for a,b in zip(item[0],item[0][1:]))),8)
return ranked
def connection_patterns(start,end,entry,destination,rhythms,meter):
ranked=connection_windows(tuple(rhythm_catalog(rhythms,meter)),end-start)
result=[]
for pattern,identifier in ranked:
events=tuple(dataclasses.replace(entry,start=start+a,end=start+b,role='connection',
event_id=f'{entry.event_id}:route:{a}',position=None) for a,b in pattern)
if len(events)>=2:
result.append(((*events,destination),identifier))
return result
def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False, tails=None):
"""Replace complete internal spans while retaining identity and sounding tails."""
legacy=tails is None
tails=tails or ()
fixed={e for f in features for e in f.events} if legacy else {n.event_id for n in notes if n.role=='quote'}
windows=[]
for i,(note,target) in enumerate(zip(notes,notes[1:])):
if note.event_id in fixed or note.end!=target.start:continue
if note.end-note.start>=F(3,2):
start=max(note.start+F(1,2),target.start-3)
windows.append((i,i+1,start,target))
if not legacy:
for j in range(i+2,min(i+5,len(notes))):
target=notes[j]
if not 1<=target.start-note.start<=3:continue
if any(n.event_id in fixed for n in notes[i:j]):continue
if any(t.start<target.start and t.end>note.start for t in tails):continue
windows.append((i,j,note.start,target))
if legacy and notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2):
tail=notes[-1]
for duration in (F(1,2),F(1),F(3,2),F(2)):
target=dataclasses.replace(tail,start=tail.end-duration,event_id=tail.event_id+':arrival')
start=max(tail.start+F(1,2),target.start-3)
if target.start-start>=1:windows.append((len(notes)-1,len(notes)-1,start,target))
# Consequential edits only; do not exhaustively branch at each passing note.
windows=sequences.diverse(windows,lambda w:(policy.stable_rank(source.id,w),),lambda w:w[0],8)
result=[]
for i,j,start,target in windows:
entry=notes[i]
prefix=dataclasses.replace(entry,end=start)
seed=dataclasses.replace(entry,start=start,event_id=entry.event_id+':tail',role='connection')
for pattern,rhythm_id in connection_patterns(start,target.start,seed,target,rhythms,meter):
for route in routes.candidates(pattern,{target.event_id},harmony,library,(41,76),'develop '+source.id):
if sustain_entry or start==entry.start:
joined=dataclasses.replace(route.notes[0],start=entry.start,event_id=entry.event_id)
route=dataclasses.replace(route,notes=(joined,*route.notes[1:]))
actual=(*notes[:i],*route.notes,*notes[j+1:])
else:
actual=(*notes[:i],prefix,*route.notes,*notes[j+1:])
if any(references.feature_error(f,actual) for f in features):continue
if any(not any(n.pitch.number()==t.pitch and n.start<=t.start and n.end>=t.end for n in actual) for t in tails):continue
if not harmony.evaluate(actual,complete=True).valid:continue
if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])):continue
route=dataclasses.replace(route,sources=(*route.sources,'rhythm:'+rhythm_id))
edit=types.DraftEdit('complete_route',start,target.start,tuple(n.event_id for n in notes[i:j+1]),tuple(n.event_id for n in route.notes),
source.id+'; '+rhythm_id+'; lead into '+target.pitch.label())
result.append((actual,edit,route))
return sequences.diverse(result,lambda v:(max(0,4-len(v[0])*4/float(source.end-source.start),len(v[0])*4/float(source.end-source.start)-6),v[2].id),
lambda v:(v[1].start,v[2].destination.end-v[2].destination.start,len(v[2].notes)),8)
def make_phrase(source, original, refined, notes, edits, route_list, octave, part, harmony, tempo, seed):
from generator.solo import realization
features=identifying(refined,source.id)
# Only fully retained source turns become committed anchor events.
anchors=[]
for i,n in enumerate(notes):
if n.pitch.number()%12 not in harmony.members(n.start,n.end):
continue
role='terminal' if i==len(notes)-1 else 'opening' if i==0 else 'intermediate'
if role=='intermediate' and n.end-n.start<2:
continue
anchors.append(types.Anchor(n.event_id+':anchor',n.start,n.start,n.pitch,harmony.members(n.start,n.end),
(41,76),n.end-n.start,role,harmony.at(n.start).chord,hold_until=n.end))
if notes and (not anchors or anchors[-1].role!='terminal'):
return None
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,signature(notes))[:12]
motif=types.Motif(source.id,tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start) for n in refined),
source.end-source.start,tuple(s.chord for s in harmony.spans if s.start<source.end and s.end>source.start),'song melody '+source.family)
home=realization.make_identity(identifier,source.id,refined,source.start,source.end,'source_variation',harmony)
transform=types.Transformation(identifier,source.id,source.family,'source_variation',source.start,source.end,
tuple(n.event_id for n in notes),tuple((n.start-source.start,n.end-source.start) for n in notes),preserves=(),source=source.id)
draft=types.MelodicDraft(identifier,source.start,source.end,notes,(transform,),
tuple(a.id.removesuffix(':anchor') for a in anchors),(),tuple(anchors),home,'source_variation',features=features,
reference=source.id,reference_motif=motif,source_phrase=source,octave_transfer=octave,
source_links=tuple((n.event_id,n.sources) for n in notes))
spaces=[];cursor=source.start
for n in notes:
if n.start>cursor:spaces.append(types.ActivitySpan(cursor,n.start,'space',F(),F()))
spaces.append(types.ActivitySpan(n.start,n.end,'active',F(5,4),F()))
cursor=n.end
if cursor<source.end:spaces.append(types.ActivitySpan(cursor,source.end,'space',F(),F()))
if not notes:spaces=[types.ActivitySpan(source.start,source.end,'space',F(),F())]
plan=types.PhrasePlan(identifier,source.start,source.end,motif,'source_variation','source_variation',None,(41,76),F(5,4),
source.end-cursor,tuple(spaces),(), 'breath' if cursor<source.end else 'sustain','closed',seed,source.id,source.start,source.end, (tuple(anchors),))
phrase=realization.checked_phrase(plan,draft,notes,(),types.ContinuationState(''),harmony,tempo,edits)
if phrase is None:
return None
# Source correspondence is evaluated once. Do not score the imported tune's
# characteristic leaps as generic contour defects or reward filling its rests.
source_by_id={n.event_id:n for n in original}
retained={n.event_id:n for n in notes if n.event_id in source_by_id}
deviation=sum(min(1,abs(n.pitch.number()-retained[k].pitch.number())/5)+
min(1,float(abs(n.start-retained[k].start))/2)+min(1,float(abs((n.end-n.start)-(retained[k].end-retained[k].start)))/2) for k,n in source_by_id.items() if k in retained)/max(1,len(source_by_id))
exposure=harmony.evaluate(notes,complete=True).exposure
density=len(notes)*4/max(.001,float(source.end-source.start))
score=3*deviation+2*exposure+.35*max(0,4-density,density-6)
source_draft=dataclasses.replace(draft,id=identifier+':source',notes=original,features=(),anchors=(),structural=())
refined_draft=dataclasses.replace(draft,id=identifier+':variation',notes=refined,features=features,anchors=(),structural=())
return dataclasses.replace(phrase,score=score,initial=source_draft,refined=refined_draft,routes=route_list)
def candidates(source, harmony, sequences_library, rhythms, meter, part, tempo, seed, *, stage='elaborated'):
result=[]
for octave in range(-3,4):
original=shift(source.notes,octave)
if any(not generator.fretboard.single_positions_for_pitch(F(n.pitch.number())) for n in original):
continue
adapted,required=adapt(original,harmony)
if original and not adapted and any(harmony.at(n.start) and harmony.at(n.start).scale for n in original):
continue
variants=list(variations(adapted,dataclasses.replace(source,pickup=shift(source.pickup,octave)),harmony))
if required or not adapted:
variants.append((adapted,()))
# A whole preserved phrase is the baseline, not an accidental production fallback.
if not variants:
continue
variants=sequences.diverse(variants,lambda v:(len(v[1]),policy.stable_rank(seed,signature(v[0]))),
lambda v:(v[1][0].kind if v[1] else 'harmonic_adaptation',v[1][0].start if v[1] else source.start),8)
for refined,changes in variants:
options=[(refined,(),())]
if stage=='elaborated':
connections=elaborate(refined,identifying(refined,source.id),harmony,sequences_library,rhythms,meter,source)
options.extend((notes,(edit,),(route,)) for notes,edit,route in connections)
# A second, distinct source-held span may develop the same phrase.
# Reuse complete routes; never elaborate an already elaborated event.
for first_notes,first_edit,first_route in connections[:2]:
fixed=(*identifying(refined,source.id),types.IdentifyingFeature('route',
tuple(n.event_id for n in first_route.notes)+first_edit.removed,(),(),source.id))
second=elaborate(first_notes,fixed,harmony,sequences_library,rhythms,meter,source)
options.extend((actual,(first_edit,edit),(first_route,route)) for actual,edit,route in second[:2])
for notes,added,route_list in options:
phrase=make_phrase(source,original,refined,notes,(*required,*changes,*added),route_list,octave,part,harmony,tempo,seed)
if phrase:
result.append(phrase)
# Preserve register AND edit-location diversity, rather than eight near-copies.
unique={signature(p.draft.notes):p for p in result}
return sequences.diverse(tuple(unique.values()),lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
lambda p:p.draft.octave_transfer,policy.DEFAULT.source_variations)
def clipped_sources(analysis,end):
result=[]
for source in analysis.source_phrases:
if source.start>=end:break
finish=min(source.end,end)
notes=tuple(dataclasses.replace(n,start=max(F(),n.start),end=min(n.end,finish)) for n in source.notes if n.start<finish and n.end>0)
result.append(dataclasses.replace(source,end=finish,notes=notes,rests=tuple((a,min(b,finish)) for a,b in source.rests if a<finish)))
return tuple(result)
def transition(first,second):
if not first or not first.decisions or not second.decisions:return 0.
a=first.state.last;b=second.decisions[0].notes[0]
leap=max(0,abs(a.pitch.number()-b.pitch.number())-7)/12
same_family=first.draft.source_phrase.family==second.draft.source_phrase.family
repeated_end=not same_family and first.anchors and second.anchors and first.anchors[-1].pitch==second.anchors[-1].pitch and first.anchors[-1].duration==second.anchors[-1].duration
return .2*leap+.15*bool(repeated_end)
def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage):
from generator.solo import realization,themes,phrasing
context.resources[id(library)]=library
context.tempo=analysis.tempo
context.resources[id(rhythms)]=rhythms
harmony=harmonic.Harmony(choices, profiles.for_part(part,analysis))
seed=policy.stable_rank('source_variations',(part,analysis.melody,choices,generator.vocabulary.role(part+'_solo'),policy.DEFAULT.composition_version))
states=[(0.,())]
failures=[]
for _ in range(2*len(measures)+1):
if all(history and history[-1].plan.end==end for _,history in states):break
following=[]
for cost,history in states:
start=history[-1].plan.end if history else F()
if start==end:
following.append((cost,history));continue
incoming=history[-1].state if history else types.ContinuationState('')
sources=phrasing.window_options(analysis,measures,start,end,harmony,incoming)
elapsed=F();meter=measures[0]['time_signature']
for measure in measures:
elapsed+=generator.model.fraction(measure['duration'])
if elapsed>start:
meter=measure['time_signature'];break
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
harmony.bar=bar
for source in sources:
key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT,harmony.profile)
if key in context.results:
pool=context.results[key];context.diagnostics['cache_hits']+=1
else:
pool=()
if not harmony.at(start) or not harmony.at(start).scale:
pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed)
else:
for batch in range(policy.DEFAULT.replan_attempts):
pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed,
stage=stage,batch=batch,final=source.end==end,solo_end=end)
if pool:break
context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1
context.results[key]=pool
context.diagnostics.setdefault('source_candidate_pools',{}).setdefault(source.id,[]).extend(
{'id':p.draft.id,'score':p.score,'attacks':len(p.draft.notes),'theme':p.state.home.id if p.state.home else None,
'ending':str(p.anchors[-1].duration) if p.anchors else None,'returns':len(p.state.returns),'evaluation':dataclasses.asdict(p.evaluation) if p.evaluation else None} for p in pool)
for candidate in pool:
span=float((source.end-source.start)/end)
total=cost+span*candidate.score
following.append((total,(*history,candidate)))
if not pool:failures.append(f'{source.id}: planned sustained destination, feature or incoming continuation')
if not following:
raise realization.SearchExhausted(f'{part}: {"; ".join(failures[-4:])} failed within the bounded search; not proof of incompatibility')
def rank(value):
cost,history=value;state=history[-1].state;elapsed=history[-1].plan.end
musical=cost/max(.001,float(elapsed/end))
return musical,policy.stable_rank(seed,tuple(p.draft.id for p in history))
states=sequences.diverse(following,rank,
lambda v:(v[1][-1].state.home.id if v[1][-1].state.home else '',v[1][-1].plan.end,len(v[1][-1].state.returns)),policy.DEFAULT.chorus_frontier)
finished=[(cost,history) for cost,history in states if history[-1].plan.end==end]
if not finished:raise realization.SearchExhausted(f'{part}: phrase-boundary search budget exhausted; not proof of incompatibility')
return tuple((rank((cost,phrases))[0],types.Solo(part,choices,phrases,analysis.tempo,end,
{**context.diagnostics,'selection_total':rank((cost,phrases))[0], 'selection_scope':'duration-weighted complete phrase evaluations; partial frontiers use elapsed-duration rates', 'construction':'thematic_recomposition','policy':policy.describe()['fingerprint']},
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases),harmony.profile)) for cost,phrases in finished)
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
from generator.solo import realization
if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode)
if mode=='thematic':
return thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage)
context.resources[id(library)]=library
context.resources[id(rhythms)]=rhythms
harmony=harmonic.Harmony(choices, profiles.for_part(part,analysis))
sources=clipped_sources(analysis,end)
seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE))
states=[(0.,())]
for source in sources:
meter=measures[min(len(measures)-1,int(source.start//generator.model.fraction(measures[0]['duration'])))]['time_signature']
following=[]
for cost,history in states:
incoming=history[-1].state if history else types.ContinuationState('')
key=(mode,source,None,policy.stable_rank("harmony",choices),part,analysis.tempo,seed,stage,id(library),id(rhythms),policy.DEFAULT)
if key in context.results:
pool=context.results[key];context.diagnostics['cache_hits']+=1
else:
pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage)
context.results[key]=pool;context.diagnostics['generated_candidates']+=len(pool)
context.diagnostics.setdefault('source_candidate_pools', {}).setdefault(source.id,[]).extend(
{'id':p.draft.id,'score':p.score,'octave':p.draft.octave_transfer,'attacks':len(p.draft.notes),'edits':[e.kind for e in p.edits],'source_family':source.family,'theme':p.state.home.id if p.state.home else None} for p in pool)
for candidate in pool:
last=incoming.last
placements=placement.alternatives(tuple(n for d in candidate.decisions for n in d.notes),analysis.tempo,last,limit=2)
for travel,notes in placements:
decision=dataclasses.replace(candidate.decisions[0],notes=notes) if candidate.decisions else None
state=dataclasses.replace(candidate.state,notes=notes,last=notes[-1] if notes else last,travel=travel)
placed=dataclasses.replace(candidate,decisions=(decision,) if decision else (),state=state)
total=cost+candidate.score*float(source.end-source.start)/float(end)+.05*travel/max(1,len(notes))/2000+transition(history[-1] if history else None,placed)
following.append((total,(*history,placed)))
if not following:
raise realization.SearchExhausted(f'{part} {source.id}: {mode} feature, harmonic destination or continuation failed within the bounded search; not proof of incompatibility')
states=sequences.diverse(following,lambda v:(v[0],policy.stable_rank(seed,tuple((p.draft.id,p.state.last.position if p.state.last else None) for p in v[1]))),
lambda v:(tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier)
return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'source_phrase_variations','policy':policy.describe()['fingerprint']},
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases),harmony.profile)) for cost,phrases in states)
def similarity(first,second):
"""Compare composition independently of octave and fingering; callbacks are explicit."""
def events(solo):
callbacks={e for p in solo.phrases if p.draft for f in p.draft.features if f.id.endswith(':callback') for e in f.events}
return tuple(n for p in solo.phrases if p.plan.relationship!='callback' for d in p.decisions for n in d.notes if n.event_id not in callbacks)
a,b=events(first),events(second)
def tokens(notes):
return {(n.start,n.end,n.pitch.number()%12) for n in notes}
left,right=tokens(a),tokens(b)
literal=len(left&right)/max(1,min(len(left),len(right)))
def runs(notes):
return {tuple((n.start-notes[i].start,n.end-n.start,n.pitch.number()-notes[i].pitch.number()) for n in notes[i:i+4]) for i in range(len(notes)-3)}
left,right=runs(a),runs(b)
interval=len(left&right)/max(1,min(len(left),len(right)))
return .65*literal+.35*interval
-56
View File
@@ -1,56 +0,0 @@
"""Realized gesture identities and their explicit development relationships."""
from __future__ import annotations
import dataclasses
import fractions
import generator.solo.harmonic
import generator.solo.scoring
import generator.solo.types
def reference(plan, gesture, realized):
if not realized or gesture is None or gesture.relationship == 'statement':
return plan.motif
# Repetition returns to the introduced identity; extension/answer develops
# the most recently heard related gesture, never an unrelated catalog winner.
return realized[0] if gesture.relationship == 'repeat_with_harmonic_adaptation' else realized[-1]
def local_notes(notes, start, end):
return tuple(dataclasses.replace(n, start=n.start - start, end=min(n.end, end) - start, position=None, continuation=False) for n in notes if start <= n.start < end)
def complete(gesture, reference, notes, harmony):
actual = local_notes(notes, gesture.start, gesture.end)
sounded = any(n.start < gesture.end and n.end > gesture.start for n in notes)
active = any(s.scale and s.start < gesture.end and s.end > gesture.start for s in harmony.spans)
if not active:
return True, 0.0, None
if gesture.relationship == 'close':
return sounded, 0.0, None
required = min(len(reference.notes), 2)
if len(actual) < required:
return False, 1.0, None
fidelity = generator.solo.scoring.motif_fidelity(actual, reference, gesture.treatment)
if gesture.treatment == 'rhythmic_reference':
expected = tuple((n.start, min(n.end, gesture.end - gesture.start)) for n in reference.notes if n.start < gesture.end - gesture.start)
if tuple((n.start, n.end) for n in actual) != expected:
return False, 1.0, None
elif gesture.relationship != 'statement' and fidelity >= 0.75:
return False, fidelity, None
degrees = []
for n in actual:
span = harmony.at(gesture.start + n.start)
degrees.append(dict(span.roles.degrees).get(n.pitch.number() % 12, ()) if span else ())
result = generator.solo.types.RealizedMotif(gesture.id, reference.id, actual, gesture.end - gesture.start, gesture.relationship, gesture.treatment, tuple(degrees), gesture.start)
return True, fidelity, result
def interval_steps(reference, harmony, source_start=None):
result = []
source_start = getattr(reference, 'source_start', source_start or fractions.Fraction())
for first, second in zip(reference.notes, reference.notes[1:]):
span = harmony.at((source_start or fractions.Fraction()) + first.start)
try:
step = generator.solo.types.scale_index(span.scale, second.pitch.number()) - generator.solo.types.scale_index(span.scale, first.pitch.number()) if span and span.scale else None
except ValueError:
step = None
result.append((step, second.pitch.number() - first.pitch.number()))
return tuple(result)
-253
View File
@@ -1,253 +0,0 @@
"""Sustained destinations and bounded memory for the existing theme composer."""
from __future__ import annotations
import dataclasses
import functools
from fractions import Fraction as F
import generator.model
import generator.harmony
import generator.vocabulary
from generator.solo import types, policy, sequences, profiles
def heard(notes, operation, anchor=None, bar=F(4)):
notes = types.sounding_notes(notes)
first = notes[0].start
events = tuple((n.start-first, n.end-n.start, n.pitch.number()) for n in notes)
ending = (anchor.pitch.number()%12, anchor.earliest%bar, anchor.duration) if anchor and anchor.role == 'terminal' else ()
return types.HeardFigure(first, notes[-1].end, events, operation, ending)
def remember(history, figure, bar):
return tuple(h for h in (*history, figure) if h.end > figure.end-policy.DEFAULT.history_bars*bar)
@functools.lru_cache(maxsize=4096)
def fingerprints(events, relative=False):
result = set()
for size in (3, 4, 6, 8):
for i in range(len(events)-size+1):
group = events[i:i+size]
start, _, pitch = group[0]
result.add(tuple((a-start, d, p-pitch if relative else p) for a,d,p in group))
return frozenset(result)
def repetition(figure, history, bar):
"""Bounded overuse, not a reward for arbitrary difference or extra attacks."""
exact, relative = fingerprints(figure.events), fingerprints(figure.events, True)
overuse = cadence = 0.
for old in history:
age = max(0., float((figure.start-old.end)/bar))
recent = max(0., 1-age/policy.DEFAULT.history_bars)
a,b = fingerprints(old.events), fingerprints(old.events, True)
exact_match = len(exact & a)/max(1,min(len(exact),len(a)))
shifted_match = len(relative & b)/max(1,min(len(relative),len(b)))
overuse += recent*(.65*exact_match+.35*shifted_match)
if figure.ending and figure.ending == old.ending:
cadence += recent
pitches = tuple(p for _,_,p in figure.events)
oscillation = sum(a==c and b==d for a,b,c,d in zip(pitches,pitches[1:],pitches[2:],pitches[3:]))/max(1,len(pitches)-3)
discount = .45 if figure.operation == 'return' else 1.
return min(1., discount*overuse/2) + .3*oscillation + .3*min(1.,cadence/2)
def register_goal(incoming, part, fallback, profile=None):
if profile and profile.reference is not None:
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
center = round(sum(recent)/len(recent)) if recent else profile.reference
# Follow a whole-gesture excursion with a possible return, not an
# instrument-specific octave or a repeating register cycle.
if center>profile.reference+7:goal=profile.reference
elif center<profile.reference-7:goal=profile.reference
else:
motion=incoming.direction*4 if incoming.home else 0
goal=profile.reference+motion
return max(profile.register[0]+3,min(profile.register[1]-3,round((center+goal)/2)))
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
if not recent:
return fallback
center = sum(recent)/len(recent)
if part == 'electric' and (center >= 67 or incoming.register_peak >= 72 and center >= 64):
return max(48, round(center)-7)
if part == 'acoustic' and max(recent)-min(recent) < 8:
home = sum(n.pitch.number() for n in incoming.home.notes)/len(incoming.home.notes) if incoming.home else center
return min(72, round(center)+5) if center <= home+7 else max(45, round(center)-5)
return round(center)
def endpoint_cost(finish, start, end, count, harmony, bar):
length = float((finish-start)/bar)
remaining = float((end-finish)/bar)
pacing = 0.
span = harmony.at(finish-F(1,100))
# A dominant may close an open question, but a nearby resolution is preferred.
degrees=generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)) if span and span.scale else {}
dominant=degrees.get(3)==0 and degrees.get(7)==-1
short_remainder=.2*max(0,4-remaining) if remaining else 0.
return pacing+(.08 if ending_effect(finish-F(1,100),harmony,False)=='local_settle' else 0)+.035*abs(length-5)+.16*dominant+.1*(length==8)+short_remainder
def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2):
from generator.solo.themes import source_notes
bounds=[F()]
for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration']))
index=next((i for i,b in enumerate(bounds[:-1]) if b<=start<bounds[i+1]),len(measures)-1)
bar=bounds[index+1]-bounds[index]
current=harmony.at(start)
if not current or not current.scale:
ends=(min(end,current.end if current else bounds[index+1]),)
else:
barrier=min((s.start for s in harmony.spans if not s.scale and s.start>start),default=end)
authored=[n.end-analysis.body_offset for n in analysis.melody if n.end-n.start>=2]
ends=tuple(sorted({min(end,barrier,bounds[min(index+n,len(measures))]) for n in (3,4,5,6,8)} | {t for t in authored if start+3*bar<=t<=min(end,barrier,start+8*bar)}))
if barrier>=end:
# The returning pickup may shorten the last measure. Do not strand a
# one-beat "new phrase" after an otherwise completed sustained close.
ends=tuple(sorted({end if 0<end-finish<bar else finish for finish in ends}))
melody=source_notes(analysis)
values=[]
for finish in ends:
if finish<=start:continue
notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.start<start)
for n in melody if n.start<finish and n.end>start)
values.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),''))
return tuple(sorted(values,key=lambda s:(endpoint_cost(s.end,start,end,incoming.closures,harmony,bar),s.end))[:limit])
def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
"""Choose pitch AND sounding interval before generating the approach."""
from generator.solo.leads import available
base=incoming.last.pitch.number() if incoming.last else source.notes[0].pitch.number() if source.notes else 55
goal=register_goal(incoming,part,base,harmony.profile)
values=[]
duration=source.end-source.start
holds=tuple(F(v) for v in generator.vocabulary.data('grammar')['ending_holds']) if duration>=4 else (min(F(2),duration/2),)
for hold in holds:
for release in map(F,generator.vocabulary.data('grammar')['ending_releases']):
arrival=source.end-release-hold
if arrival-source.start<F(3,2):continue
for pitch in available(harmony,arrival,arrival+hold,members=True):
if abs(pitch.number()-goal)>12:continue
anchor=types.Anchor(f'{source.id}:close:{arrival}:{pitch.number()}',arrival,arrival,pitch,
harmony.members(arrival,arrival+hold),harmony.profile.register,hold,'terminal' if final or ending_effect(arrival,harmony)=='cadential_arrival' else 'intermediate',harmony.at(arrival).chord,
hold_until=arrival+hold,effect=ending_effect(arrival,harmony,final))
cadence=sum(h.ending==(pitch.number()%12,arrival%bar,hold) for h in incoming.history)
rank=harmony.profile.cost(pitch.number())+destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)
values.append((rank,anchor))
selected=destination_options(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
lambda x:x[1].pitch,lambda x:x[1].earliest,harmony,limit)
return tuple(a for _,a in selected)
def appearance_cost(operation, incoming, start, end):
gap=start-(incoming.returns[-1] if incoming.returns else F())
if operation=='return':
return .5*max(0,1-float(gap)/16)
return .1*min(1,max(0,float(gap)/32-1))
def activity(source, ending, arrivals=()):
if arrivals:
holds=[(n.start,n.end) for n in arrivals]
if ending:holds.append((ending.earliest,ending.hold_until))
points=sorted({source.start,source.end,*(t for a,b in holds for t in (a,b)),*([ending.hold_until] if ending else [])})
result=[]
for a,b in zip(points,points[1:]):
held=any(x<=a and b<=y for x,y in holds)
space=bool(ending and a>=ending.hold_until)
result.append(types.ActivitySpan(a,b,'space' if space else 'sustained_arrival' if held else 'active',
F() if held or space else F(5,4),F(1) if held else F()))
return tuple(result)
if ending is None:return (types.ActivitySpan(source.start,source.end,'active',F(5,4),F()),)
spans=[]
if ending.earliest>source.start:
spans.append(types.ActivitySpan(source.start,ending.earliest,'active',F(5,4),F()))
spans.append(types.ActivitySpan(ending.earliest,ending.hold_until,'sustained_arrival',F(),F(1)))
if ending.hold_until<source.end:
spans.append(types.ActivitySpan(ending.hold_until,source.end,'space',F(),F()))
return tuple(spans)
@functools.lru_cache(maxsize=512)
def defining_members(symbol):
"""Written chord identity, without inventing omitted thirds or sevenths."""
chord=generator.harmony.parse_symbol(symbol)
degrees=generator.harmony.degrees(chord)
root=generator.model.NATURAL[chord['root']['letter']]+generator.model.alteration(chord['root']['alter'])
selected={d for d in (3,6,7) if d in degrees}
if chord['kind'] in ('suspended-second','suspended-fourth'):
selected.update(d for d in (2,4) if d in degrees)
if degrees.get(5,0):selected.add(5)
return frozenset(int(root+generator.harmony.degree_semitones(d,degrees[d]))%12 for d in selected)
def destination_cost(pitch, start, end, harmony, incoming=None, goal=None, terminal=False):
"""Shared bounded function/trajectory cost at retrieval and final selection."""
span=harmony.at(start)
if not span or not span.scale:return float('inf')
number=pitch.number(); pc=number%12
if pc not in harmony.members(start,end):return float('inf')
tonic=generator.model.parse_pitch_name(span.scale.root)
tonic_pc=int(generator.model.NATURAL[tonic['letter']]+generator.model.alteration(tonic['alter']))%12
function=0. if pc in defining_members(span.chord) or terminal and pc==tonic_pc else .25
register=min(.15,abs(number-goal)/80) if goal is not None else 0.
entry=min(.15,max(0,abs(number-incoming.last.pitch.number())-7)/80) if incoming and incoming.last else 0.
previous=next((h.ending[0] for h in reversed(incoming.history) if h.ending),None) if incoming else None
recurrence=.04 if previous==pc else 0.
following=next((s for s in harmony.spans if s.start>=end and s.scale and s.roles.basic),None)
lookahead=0.
if following:
future=[p for p in range(max(41,number-12),min(76,number+12)+1) if p%12 in following.roles.basic and p%12 in following.scale.pitches]
if future:lookahead=.04*min(1,min(abs(p-number) for p in future)/5)
# Contour comes from the heard theme, not a universal upward/nearest rule.
direction=0.
if incoming and incoming.home and len(incoming.home.notes)>1 and incoming.last:
motion=incoming.home.notes[-1].pitch.number()-incoming.home.notes[0].pitch.number()
direction=.025 if (number-incoming.last.pitch.number())*motion<0 else 0.
pending=.08 if incoming and incoming.next_destination and pc not in incoming.next_destination else 0.
return function+register+entry+recurrence+lookahead+direction+pending
def destination_family(pitch, time, harmony):
return next((degrees for pc,degrees in harmony.at(time).roles.degrees if pc==pitch.number()%12),())
def destination_options(values, rank, pitch, time, harmony, limit):
"""Keep chord-degree representatives before register/timing variants."""
ordered=sorted(values,key=rank)
selected=[];seen=set()
for value in ordered:
family=destination_family(pitch(value),time(value),harmony)
if family not in seen:
selected.append(value);seen.add(family)
if len(selected)==limit:return tuple(selected)
for value in ordered:
if value not in selected:selected.append(value)
if len(selected)==limit:break
return tuple(selected)
def ending_effect(time, harmony, final=False):
if final: return 'handoff'
current=harmony.at(time)
previous=next((s for s in reversed(harmony.spans) if current and s.end<=current.start and s.chord!=current.chord and s.scale),None)
if not current or not current.scale or not previous: return 'local_settle'
old=generator.harmony.parse_symbol(previous.chord)
new=generator.harmony.parse_symbol(current.chord)
degrees=generator.harmony.degrees(old)
def root(chord):
p=chord['root'];return int(generator.model.NATURAL[p['letter']]+generator.model.alteration(p['alter']))%12
if degrees.get(3)==0 and degrees.get(7)==-1 and (root(old)-root(new))%12==7:
return 'cadential_arrival'
return 'local_settle'
def next_destination(anchor,harmony):
if anchor.effect!='local_settle': return ()
following=[s for s in harmony.spans if s.start>=anchor.hold_until and s.scale]
resolution=next((s for s in following[:2] if ending_effect(s.start,harmony)=='cadential_arrival'),None)
selected=resolution or next(iter(following),None)
return tuple(sorted(selected.roles.basic & selected.scale.pitches)) if selected else ()
-220
View File
@@ -1,220 +0,0 @@
from __future__ import annotations
import dataclasses
import fractions
import generator.fretboard
import generator.harmony
import generator.model
import generator.scales.selection
import generator.solo.harmonic
import generator.solo.analysis
import generator.solo.policy
import generator.solo.sequences
import generator.solo.types
def target_pitches(chord: dict, final: bool) -> frozenset[int]:
degrees = generator.harmony.degrees(chord)
explicit = {d['value'] for d in chord.get('degrees', []) if d['type'] != 'subtract'}
preferred = {1, 3} if final else {3, 7}
preferred |= explicit | {d for d, a in degrees.items() if d == 5 and a}
if 3 not in degrees:
preferred |= {2, 4}
root = generator.scales.selection.root_number(chord)
return frozenset(int(root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items() if d in preferred) or frozenset(generator.harmony.pitch_classes(chord))
def destination(choices, start, end, final=False, release=fractions.Fraction(1, 2), closure='closed'):
last = next((c for c in reversed(choices) if c.span.start < end and c.span.end > start), None)
if not last or not last.span.chord:
return None
latest = end - release - fractions.Fraction(1, 4)
if last.span.start > latest:
return None
pitches = target_pitches(last.span.chord, final)
if closure == 'open' and last.scale:
root = generator.scales.selection.root_number(last.span.chord)
degrees = generator.harmony.degrees(last.span.chord)
additions = {int(root + generator.harmony.degree_semitones(d, degrees.get(d, 0))) % 12 for d in (5, 9, 11, 13) if d not in {v['value'] for v in last.span.chord.get('degrees', []) if v['type'] == 'subtract'}}
pitches = frozenset(additions & last.scale.pitches) or pitches
return generator.solo.types.TargetEvent(max(start, last.span.start, end - 2 - release), latest, pitches, generator.harmony.symbol(last.span.chord), 'open continuation' if closure == 'open' else 'phrase resolution' if final else 'defining chord-tone arrival')
def new_motif(sequences: generator.solo.sequences.Library, length: fractions.Fraction, *, choices=(), register=(55, 74), seed='', variant=0) -> generator.solo.types.Motif:
if choices and any(c.scale for c in choices):
scale = next(c.scale for c in choices if c.scale)
arrival = next(c for c in reversed(choices) if c.scale)
count = min(6, max(3, int(length * fractions.Fraction(3, 2))))
shapes = (('rising', 4), ('falling', -4), ('arch', 1), ('valley', -1), ('leap_return', 2), ('neighbor', 0))
shape, displacement = shapes[(int(generator.solo.policy.stable_rank(seed, 'invention')[:8], 16) + variant) % len(shapes)]
paths = sequences.retrieve(count, displacement, (displacement > 0) - (displacement < 0))
candidates = []
target = target_pitches(arrival.span.chord, False)
for path in paths:
for start in range(-14, 15):
pitches = tuple(generator.solo.types.scale_pitch(scale, start + offset) for offset in path.offsets())
if not all(register[0] <= pitch.number() <= register[1] and generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch.number())) for pitch in pitches):
continue
score = (generator.solo.sequences.family(path.steps) != shape, pitches[-1].number() % 12 not in target, abs(path.offsets()[-1] - displacement), abs(pitches[0].number() - (register[0] + 5)), generator.solo.policy.stable_rank(seed, (variant, path.steps, start)))
candidates.append((score, pitches, path))
if candidates:
_, pitches, path = min(candidates, key=lambda c: c[0])
unit = min(fractions.Fraction(1, 2), length / count)
notes = tuple(generator.solo.types.Note(i * unit, (i + 1) * unit, pitch) for i, pitch in enumerate(pitches))
identifier = generator.solo.policy.stable_rank(seed, (variant, path.steps))[:12]
return generator.solo.types.Motif('invented_' + identifier, notes, length, (), 'sequences ' + ' + '.join(path.sources))
paths = sequences.connecting(3) or sequences.connecting(2) or sequences.connecting(4)
if not paths:
raise ValueError('solo composition requires a two-, three-, or four-note sequence')
path = min(paths, key=lambda s: (sum(a * b >= 0 for a, b in zip(s.steps, s.steps[1:])), s.rank()))
count = len(path.steps) + 1
unit = min(fractions.Fraction(1, 2), length / (count + 1))
notes = []
for i, offset in enumerate(path.offsets()):
octave, degree = divmod(28 + offset, 7)
pitch = generator.solo.types.Pitch(generator.model.LETTERS[degree], fractions.Fraction(), octave)
notes.append(generator.solo.types.Note(unit * i, unit * (i + 1), pitch))
return generator.solo.types.Motif('new_' + path.id.replace('/', '_').removesuffix('.xml'), tuple(notes), length, (), 'sequence ' + path.id)
def choose_motifs(analysis, sequences, length):
eligible = [m for m in analysis.motifs if m.duration <= 2 * length]
head = next((m for m in eligible if len(m.notes) >= 2), eligible[0] if eligible else new_motif(sequences, length))
others = [m for m in eligible if m.id != head.id]
contrast = min(others, key=lambda m: (m.contour() == head.contour(), abs(len(m.notes) - 3), m.duration, m.id)) if others else head
return head, contrast, new_motif(sequences, length)
def activity_passages(start, end, release, activity, shape=0):
music_end = end - release
boundaries = sorted({start, music_end} | {start + fractions.Fraction(round(float((end - start) * i / 4) * 2), 2) for i in (1, 2, 3) if start < start + fractions.Fraction(round(float((end - start) * i / 4) * 2), 2) < music_end})
weights = ((1, 1.6, 1.1), (0.8, 1.7, 1.2), (1.5, 1.4, 0.8))[shape % 3]
spans = list(zip(boundaries, boundaries[1:]))
desired = activity * (end - start)
denominator = sum(float(b - a) * weights[min(i, 2)] for i, (a, b) in enumerate(spans))
result = []
for i, (a, b) in enumerate(spans):
rate = fractions.Fraction(str(min(2, float(desired) * weights[min(i, 2)] / max(denominator, 0.001)))).limit_denominator(1000)
occupancy = generator.solo.policy.DEFAULT.active_occupancy
result.append(generator.solo.types.ActivitySpan(a, b, 'active', rate, fractions.Fraction(str(occupancy))))
if release:
result.append(generator.solo.types.ActivitySpan(music_end, end, 'space', fractions.Fraction(), fractions.Fraction()))
return tuple(result)
def decision(seed, name):
return int(generator.solo.policy.stable_rank(seed, name)[:8], 16)
def same_harmony(left, right):
return bool(left and right and left.span.chord and right.span.chord and generator.harmony.symbol(left.span.chord) == generator.harmony.symbol(right.span.chord))
def resolutions(choices):
distinct = []
for choice in choices:
if not distinct or not same_harmony(distinct[-1], choice):
distinct.append(choice)
return tuple(right for left, right in zip(distinct, distinct[1:]) if left.span.chord and right.span.chord and generator.scales.selection.dominant(left.span.chord) and (generator.scales.selection.root_number(left.span.chord) - generator.scales.selection.root_number(right.span.chord)) % 12 == 7)
def anchor_backbones(identifier, start, end, release, register, choices, bar, seed):
active = [c for c in choices if c.scale and c.span.start < end - release and c.span.end > start]
if not active:
return tuple([()])
finish = min(end - release, active[-1].span.end)
terminal_duration = min(fractions.Fraction(2), (finish - start) / 2)
terminal_start = max(start, active[-1].span.start, finish - terminal_duration)
terminal_duration = finish - terminal_start
opening = max(start, active[0].span.start)
moments = [(opening, 'opening')]
meaningful = list(resolutions(active)) + [b for a, b in zip(active, active[1:]) if a.scale.pitches != b.scale.pitches]
for choice in sorted(meaningful, key=lambda c: c.span.start):
time = choice.span.start
if time >= moments[-1][0] + bar * fractions.Fraction(3, 2) and time + bar < terminal_start and len(moments) < 4:
moments.append((time, 'intermediate'))
if terminal_start <= opening + fractions.Fraction(1, 2):
moments = []
moments.append((terminal_start, 'terminal'))
states = [(0.0, (), intention) for intention in ('rising', 'falling', 'returning', 'repeated')]
for index, (time, role) in enumerate(moments):
choice = next(c for c in active if c.span.start <= time < c.span.end)
chord_roles = generator.solo.harmonic.roles(choice.span.chord)
eligible = (chord_roles.basic | chord_roles.specified) & choice.scale.pitches
duration = terminal_duration if role == 'terminal' else fractions.Fraction(1, 2)
latest = time if role == 'terminal' else min(time + fractions.Fraction(3, 2), choice.span.end - duration, terminal_start - duration)
pitches = [generator.solo.types.scale_pitch(choice.scale, degree) for degree in range(-21, 28)]
pitches = [p for p in pitches if register[0] <= p.number() <= register[1] and p.number() % 12 in eligible and generator.fretboard.single_positions_for_pitch(fractions.Fraction(p.number()))]
desired = register[0] + (5 if index == 0 else 8 if role == 'terminal' else 11)
following = []
for cost, anchors, intention in states:
for pitch in pitches:
jump = abs(pitch.number() - anchors[-1].pitch.number()) if anchors else 0
direction = 1 if intention == 'rising' or intention == 'returning' and role != 'terminal' else -1 if intention != 'repeated' else 0
wrong_way = bool(anchors and (pitch.number() - anchors[-1].pitch.number()) * direction < 0)
preferred = target_pitches(choice.span.chord, role == 'terminal')
destination_motion = 0 if intention == 'repeated' else 4
score = cost + abs(jump - destination_motion) / 12 + 0.8 * wrong_way + abs(pitch.number() - desired) / 36 + 0.3 * (pitch.number() % 12 not in chord_roles.basic) + 0.2 * (role == 'terminal' and pitch.number() % 12 not in preferred)
anchor = generator.solo.types.Anchor(f'{identifier}_anchor_{index + 1}', time, latest, pitch, frozenset(eligible), register, duration, role, generator.harmony.symbol(choice.span.chord), False, finish if role == 'terminal' else None)
following.append((score, (*anchors, anchor), intention))
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], generator.solo.policy.stable_rank(seed, tuple(a.pitch for a in item[1]))), lambda item: (item[2], item[1][0].pitch.number()), generator.solo.policy.DEFAULT.anchor_lines))
return tuple(dict.fromkeys(anchors for _, anchors, _ in states))
def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choices, end: fractions.Fraction, part: str, sequences: generator.solo.sequences.Library, *, lead=False) -> tuple[generator.solo.types.PhrasePlan, ...]:
boundaries = [fractions.Fraction()]
for measure in measures:
boundaries.append(min(end, boundaries[-1] + generator.model.fraction(measure['duration'])))
head, contrast, _ = choose_motifs(analysis, sequences, boundaries[1])
seed = generator.solo.policy.stable_rank(generator.solo.analysis.identity(analysis, measures, part), generator.solo.policy.SEED_NAMESPACE)
activity = fractions.Fraction(str(generator.solo.policy.DEFAULT.acoustic_activity if part == 'acoustic' else generator.solo.policy.DEFAULT.electric_activity))
result, index, passage_index, inventions = [], 0, 0, {}
cadences = resolutions(choices)
while index < len(measures) and boundaries[index] < end:
start = boundaries[index]
stops = sorted({min(index + size, len(measures)) for size in (4, 6, 8)})
def destination_rank(stop):
finish = boundaries[stop]
last = next((c for c in reversed(choices) if c.scale and c.span.start < finish and c.span.end > start), None)
resolved = bool(last and any(start < c.span.start < finish and same_harmony(c, last) for c in cadences))
dominant = bool(last and generator.scales.selection.dominant(last.span.chord))
return (0 if resolved else 2 if dominant else 1), abs(stop - index - 4), finish
stop = min(stops, key=destination_rank)
finish = boundaries[stop]
if finish <= start:
break
passage_index += 1
passage_id = f'{part}_passage_{passage_index}'
passage_seed = generator.solo.policy.stable_rank(seed, (str(start), str(finish)))
low, high = (55, 74) if part == 'acoustic' else (59, 79)
phase = (start + finish) / (2 * end)
register = (low + (5 if fractions.Fraction(1, 2) <= phase < fractions.Fraction(3, 4) else 0), high if phase >= fractions.Fraction(1, 2) else high - 4)
register = generator.fretboard.single_register(register)
bar = boundaries[index + 1] - start
release = fractions.Fraction(1, 2) if decision(passage_seed, 'breathing') % 3 == 0 else fractions.Fraction()
release = min(release, (finish - start) / 8)
envelope = activity_passages(start, finish, release, activity, decision(passage_seed, 'activity') % 3)
anchors = () if lead else anchor_backbones(passage_id, start, finish, release, register, choices, bar, passage_seed)
# Prefer four-bar phrasing; extend when a sustained harmony crosses that seam.
middle = min(index + 4, stop)
before = next((c for c in reversed(choices) if c.span.start < boundaries[middle]), None)
after = next((c for c in choices if c.span.start <= boundaries[middle] < c.span.end), None)
extended = stop - index > 4 and same_harmony(before, after)
# A bounded complete lead draft can express an extended phrase directly.
# Computational note groups below do not require additional phrase ends.
phrase_stops = [stop] if lead or extended or middle == stop else [middle, stop]
local = tuple(c for c in choices if c.span.start < boundaries[phrase_stops[0]] and c.span.end > start)
slot = decision(passage_seed, 'motif') % 3
if part == 'acoustic':
slot = 0 if passage_index <= 2 else slot
if slot == 2 and 0 not in inventions:
inventions[0] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed)
motif = head if slot == 0 else contrast if slot == 1 else inventions[0]
else:
slot = 0 if passage_index == 1 else slot
if slot < 2 and slot not in inventions:
inventions[slot] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed, variant=slot)
motif = head if slot == 2 else inventions[slot]
phrase_start = start
for phrase_stop in phrase_stops:
phrase_end = boundaries[phrase_stop]
terminal = phrase_end == finish
treatment = 'lead' if lead else 'opening_reference' if motif.origin == 'melody' else 'invented'
landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, min(v[i].pitch.number() for v in anchors), max(v[i].pitch.number() for v in anchors), 0, a.role) for i, a in enumerate(anchors[0])) if anchors and anchors[0] else ()
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', phrase_start, phrase_end, motif, 'statement' if phrase_start == start else 'continue', treatment, None, register, activity, release if terminal else fractions.Fraction(), envelope, landmarks, 'breath' if terminal and release else 'sustain' if terminal else 'connected', 'closed' if terminal else 'open', passage_seed, passage_id, start, finish, anchors))
phrase_start = phrase_end
index = stop
return tuple(result)
+6 -62
View File
@@ -1,66 +1,10 @@
"""One curated, deterministic composition policy; all times are quarter-note beats."""
from __future__ import annotations
import dataclasses
import hashlib
import json
"""Physical/harmonic constants and semantic generation version."""
import dataclasses, hashlib
@dataclasses.dataclass(frozen=True)
class Policy:
acoustic_activity: float = 1.125
electric_activity: float = 1.125
active_occupancy: float = 0.925
arrival_occupancy: float = 0.9
weights: tuple[tuple[str, float], ...] = (('activity', 4), ('occupancy', 4), ('landmarks', 3), ('register', 1), ('contour', 1), ('connection', 1), ('repetition', 1), ('movement', 0.5), ('harmonic_exposure', 2), ('draft_preservation', 3), ('development', 2), ('direction', 1), ('join_coherence', 2), ('ending_integration', 2))
beam_width: int = 64
extensions: int = 32
retrieval: int = 32
alternatives: int = 4
long_note: float = 2.0
stopping_note: float = 1.0
suspension_preparation: float = 0.5
suspension_resolution: float = 1.0
replan_attempts: int = 2
anchor_lines: int = 8
draft_frontier: int = 16
phrase_alternatives: int = 8
join_alternatives: int = 8
revision_sweeps: int = 2
revision_joins: int = 2
refinement_alternatives: int = 4
join_window: float = 2.0
activity_deadband: float = 1.0
occupancy_deadband: float = 0.1
lead_activity_range: tuple[float, float] = (1.0, 1.5)
lead_occupancy_range: tuple[float, float] = (0.85, 1.0)
source_variations: int = 8
chorus_frontier: int = 8
theme_copy_threshold: float = 0.8
theme_copy_beats: float = 8.0
theme_copy_attacks: int = 4
theme_seed_limit: int = 8
theme_unit_alternatives: int = 8
theme_rhythm_alternatives: int = 8
theme_destinations: int = 6
theme_continuations: int = 8
theme_route_jobs: int = 16
settling_range: tuple[int, int] = (5, 7)
history_bars: int = 16
enclosure_targets: int = 4
enclosure_routes: int = 4
enclosure_frontier: int = 8
enclosure_max_per_32_bars: int = 4
composition_version: str = 'joint-arrivals-2'
# Preserve the former default root seeds while decoupling them from policy edits.
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
DEFAULT = Policy()
def describe():
values = dataclasses.asdict(DEFAULT)
identity = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()
return {'fingerprint': identity, 'seed_namespace': SEED_NAMESPACE, 'parameters': values}
long_note: float = 2.
stopping_note: float = 1.
composition_version: str = 'phrase-composer-1'
DEFAULT=Policy()
def stable_rank(seed, value):
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
-935
View File
@@ -1,935 +0,0 @@
from __future__ import annotations
import dataclasses
import fractions
import functools
import time
import generator.solo.drafts
import generator.solo.features
import generator.solo.routes
import generator.fretboard
import generator.model
import generator.rhythm
import generator.scales.selection
import generator.solo.harmonic
import generator.solo.motifs
import generator.solo.placement
import generator.solo.planning
import generator.solo.scoring
import generator.solo.sequences
import generator.solo.policy
import generator.solo.types
BEAM_WIDTH = generator.solo.policy.DEFAULT.beam_width
CELL_LIMIT = generator.solo.policy.DEFAULT.extensions
class SearchExhausted(ValueError):
pass
@dataclasses.dataclass(frozen=True)
class Cell:
start: fractions.Fraction
end: fractions.Fraction
patterns: tuple[tuple[str, tuple[tuple[fractions.Fraction, fractions.Fraction], ...]], ...]
motif: generator.solo.types.Motif | None = None
gesture: generator.solo.types.Gesture | None = None
@dataclasses.dataclass(frozen=True)
class Candidate:
notes: tuple[generator.solo.types.Note, ...]
source: str
treatment: str
rank: tuple
fidelity: float = 0
treatments: tuple[generator.solo.types.HarmonicTreatment, ...] = ()
def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fractions.Fraction, activity: fractions.Fraction, offset=fractions.Fraction()):
result = {}
for snippet in library.for_meter(meter):
for index, bar in enumerate(snippet.bars):
spans = []
for event in bar.events:
if not event.sounding:
continue
if event.continuation:
if spans and spans[-1][1] == event.onset:
spans[-1] = (spans[-1][0], event.onset + event.duration)
else:
spans.append((event.onset, event.onset + event.duration))
# Slice exact authored rhythm; later attacks outside the window do not
# disqualify a useful prefix. A tied continuation never becomes an attack.
pattern = tuple((start - offset, min(end - offset, length)) for start, end in spans if offset <= start < offset + length)
result.setdefault(pattern, f'rhythm {snippet.id}:{index + 1}@{offset}+{length}')
def rank(item):
pattern, identifier = item
return abs(len(pattern) - length * activity), abs(sum(end - start for start, end in pattern) - length * fractions.Fraction(3, 4)), identifier
def family(item):
pattern = item[0]
return len(pattern), bool(pattern and pattern[0][0]), any(a.denominator > 1 for a, _ in pattern)
selected = generator.solo.sequences.diverse(result.items(), rank, family, 12)
return tuple((identifier, pattern) for pattern, identifier in selected)
def cells(plan, measures, rhythms, choices=()):
result, boundaries = [], [fractions.Fraction()]
for measure in measures:
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
start, music_end = plan.start, plan.end - plan.release
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
if protected:
motif = plan.motif
if not motif.notes or motif.duration > music_end - start:
raise SearchExhausted(f'{plan.id}: protected motif rhythm does not fit the phrase')
spans = tuple((n.start, n.end) for n in motif.notes)
result.append(Cell(start, start + motif.duration, ((motif.id, spans),), motif))
start += motif.duration
while start < music_end:
index = next(i for i in range(len(measures)) if boundaries[i] <= start < boundaries[i + 1])
passage = next((p for p in plan.passages if p.start <= start < p.end), None)
gesture = next((g for g in plan.gestures if g.start <= start < g.end), None)
chord_end = next((c.span.end for c in choices if c.span.start <= start < c.span.end), music_end)
end = min(music_end, boundaries[index + 1], passage.end if passage else music_end, gesture.end if gesture else music_end, chord_end)
activity = passage.activity if passage else plan.activity
patterns = rhythm_patterns(rhythms, measures[index]['time_signature'], end - start, activity, start - boundaries[index])
patterns += (('hold', ((fractions.Fraction(), end - start),)), ('space', ()))
if plan.target and start <= plan.target.latest and plan.target.earliest < end:
onset = max(start, plan.target.earliest) - start
patterns = (('arrival_and_release', ((onset, end - start),)), *patterns)
motif = plan.motif if not result else None
result.append(Cell(start, end, patterns, motif, gesture))
start = end
if plan.release:
result.append(Cell(music_end, plan.end, (('planned_release', ()),)))
return tuple(result)
@functools.lru_cache(maxsize=2048)
def scale_options(scale, allowed, low, high):
return tuple(generator.solo.types.scale_pitch(scale, index) for index in range(-21, 28) if low <= generator.solo.types.scale_pitch(scale, index).number() <= high and generator.solo.types.scale_pitch(scale, index).number() % 12 in allowed and generator.fretboard.single_positions_for_pitch(fractions.Fraction(generator.solo.types.scale_pitch(scale, index).number())))
def valid_notes(notes, choices):
return all(n.pitch.number() % 12 in generator.solo.types.permitted(choices, n.start, n.end) for n in notes)
def options(choices, start, end, register):
scale = next((c.scale for c in choices if c.span.start <= start < c.span.end), None)
allowed = generator.solo.types.permitted(choices, start, end)
return scale_options(scale, allowed, *generator.fretboard.single_register((register[0] - 7, register[1] + 5))) if scale and allowed else ()
def target_ok(notes, target):
return target is None or any(target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches for n in notes)
def adapt_path(pitches, spans, choices, register):
"""Connect actual pitches across collections without carrying a stale degree index."""
states = [(0, ())]
for index, ((start, end), intended) in enumerate(zip(spans, pitches)):
available = options(choices, start, end, register)
following = []
for cost, path in states:
desired = path[-1].pitch.number() + intended.number() - pitches[index - 1].number() if path else intended.number()
for pitch in sorted(available, key=lambda p: (abs(p.number() - desired), p))[:2]:
following.append((cost + abs(pitch.number() - desired), (*path, generator.solo.types.Note(start, end, pitch, role='adapted_path'))))
states = sorted(following, key=lambda item: (item[0], tuple(n.pitch for n in item[1])))[:8]
return [notes for _, notes in states]
def anchor_treatments(anchors, harmony):
result = []
for anchor in anchors:
span = harmony.at(anchor.earliest)
if span and anchor.pitch.number() % 12 in span.roles.specified:
result.append(generator.solo.types.HarmonicTreatment(anchor.id + '_color', 'color', anchor.pitch.number(), anchor.earliest, anchor.hold_until or anchor.latest + anchor.duration))
return tuple(result)
def rhythm_variants(cell, plan, state, anchors, reference):
patterns = cell.patterns
gesture = cell.gesture
if cell.motif and plan.treatment in ('quote', 'exact_quote'):
return [(name, tuple((cell.start + a, cell.start + b) for a, b in pattern), {}, False) for name, pattern in patterns]
if gesture and gesture.treatment == 'rhythmic_reference' and state.motifs:
# The identity is the rhythm actually heard, including its rests. Cropping
# at a search boundary does not create a new rhythmic attack.
pattern = tuple((max(cell.start, gesture.start + n.start) - cell.start, min(cell.end, gesture.start + n.end) - cell.start) for n in reference.notes if gesture.start + n.start < cell.end and gesture.start + n.end > cell.start)
patterns = ((reference.id + ':rhythm', pattern),)
due = [a for a in anchors if cell.start <= a.latest and a.earliest < cell.end and not generator.solo.harmonic.anchor_note(a, state.notes)]
holding = next((a for a in anchors if state.last and state.last.role != 'quote' and a.hold_until and a.hold_until > cell.start and generator.solo.harmonic.anchor_note(a, state.notes, until=cell.start)), None)
if holding:
stop = min(cell.end, holding.hold_until)
return [('anchor_sustain', ((cell.start, stop),), {cell.start: holding.pitch}, True)]
result = []
for name, pattern in patterns:
spans = tuple((cell.start + a, cell.start + b) for a, b in pattern)
variants = [(spans, {})]
for anchor in due:
following = []
for current, forced in variants:
onsets = [a for a, b in current if anchor.earliest <= a <= anchor.latest and b - a >= min(anchor.duration, cell.end - a)]
if gesture and gesture.treatment == 'rhythmic_reference':
times = onsets[:2]
else:
times = list(dict.fromkeys(onsets[:2] + [max(cell.start, anchor.earliest)]))
for time in times:
stop = min(cell.end, anchor.hold_until or time + anchor.duration)
original_end = next((b for a, b in current if a == time), stop)
stop = max(stop, original_end)
if gesture and gesture.treatment == 'rhythmic_reference' and stop != original_end:
continue
overlaid = [(a, min(b, time)) for a, b in current if a < time]
overlaid += [(time, stop)] + [(a, b) for a, b in current if a >= stop]
following.append((tuple((a, b) for a, b in overlaid if b > a), {**forced, time: anchor.pitch}))
variants = following
result.extend((name, spans, forced, False) for spans, forced in variants)
return result
def realize_route(spans, first, steps, forced, choices, plan, harmony, contracts, reference):
"""Instantiate relative motion at actual pitches, adapting only at collection changes."""
path = []
for index, (start, end) in enumerate(spans):
available = options(choices, start, end, plan.register)
if not available:
return None
if start in forced:
available = tuple(p for p in available if p.number() == forced[start].number())
if end - start >= generator.solo.policy.DEFAULT.long_note:
members = harmony.members(start, end)
available = tuple(p for p in available if p.number() % 12 in members or any(t.pitch == p.number() and t.start <= start and t.end >= end for t in contracts))
if not available:
return None
intended = first.number()
if path:
prior = path[-1].pitch.number()
scale = harmony.at(start).scale
step, semitones = steps[(index - 1) % len(steps)] if steps else (0, 0)
try:
intended = generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, prior) + step).number() if step is not None else prior + semitones
except ValueError:
# The old pitch is not in the new collection: preserve its sounding
# coordinate and adapt its intended motion, never transfer degree IDs.
intended = prior + semitones
stopping = end - start >= generator.solo.policy.DEFAULT.stopping_note
basic = harmony.at(start).roles.basic
pitch = min(available, key=lambda p: (abs(p.number() - intended) + (0.6 if stopping and p.number() % 12 not in basic else 0), p))
source = reference.notes[min(index, len(reference.notes) - 1)].sources if reference.notes else ()
path.append(generator.solo.types.Note(start, end, pitch, source, 'motif'))
return tuple(path)
def candidates(cell, plan, choices, sequences, state=None, anchors=(), harmony=None):
state = state or generator.solo.types.ContinuationState(plan.passage_id)
harmony = harmony or generator.solo.harmonic.Harmony(choices)
reference = generator.solo.motifs.reference(plan, cell.gesture, state.motifs)
treatment = cell.gesture.treatment if cell.gesture else plan.treatment if cell.motif else 'continue'
contracts = tuple(dict.fromkeys((*state.treatments, *anchor_treatments(anchors, harmony))))
protected = cell.motif and plan.treatment in ('quote', 'exact_quote')
local_plan = dataclasses.replace(plan, start=cell.start, end=cell.end, release=fractions.Fraction(), passages=tuple(dataclasses.replace(p, start=max(cell.start, p.start), end=min(cell.end, p.end)) for p in plan.passages if p.start < cell.end and p.end > cell.start))
evaluation = generator.solo.scoring.Evaluation(local_plan, choices)
result = []
for identifier, spans, forced, sustain in rhythm_variants(cell, plan, state, anchors, reference):
active = tuple((a, b) for a, b in spans if generator.solo.types.permitted(choices, a, b))
if active != spans:
if protected:
continue
spans = active
identifier += ':harmonic_fragment'
if not spans:
if not protected:
result.append(Candidate((), identifier, 'space', (generator.solo.scoring.value(evaluation.features(())),), treatments=contracts))
continue
available = options(choices, *spans[0], plan.register)
pending = next((t for t in state.treatments if t.kind == 'suspension' and t.deadline >= cell.start and state.last and state.last.pitch.number() == t.pitch and state.last.end == cell.start and not any(n.pitch.number() == t.resolution and n.start >= t.start for n in state.notes)), None)
if pending:
resolution = next((p for p in available if p.number() == pending.resolution), None)
if resolution is None or spans[0][0] != cell.start:
continue
forced = {**forced, spans[0][0]: resolution}
sustain = False
last = state.last
next_anchor = next((a for a in anchors if a.latest >= cell.start and not generator.solo.harmonic.anchor_note(a, state.notes)), None)
destination = next_anchor.pitch.number() if next_anchor else last.pitch.number() if last else reference.notes[0].pitch.number()
if protected:
paths = []
for octave in (0, 1, -1, 2, -2):
notes = tuple(generator.solo.types.Note(a, b, dataclasses.replace(n.pitch, octave=n.pitch.octave + octave), n.sources, 'quote') for (a, b), n in zip(spans, cell.motif.notes))
if valid_notes(notes, choices):
paths.append((notes, f'quote octave {octave:+}'))
elif sustain and last:
paths = [((generator.solo.types.Note(*spans[0], last.pitch, role='sustain', continuation=True),), 'anchor_sustain')]
else:
near = last.pitch.number() if last else destination
starts = sorted(available, key=lambda p: (abs(p.number() - near), p))[:3]
if spans[0][0] in forced:
starts = [forced[spans[0][0]]]
scale = harmony.at(spans[0][0]).scale
target = min(available, key=lambda p: abs(p.number() - destination))
paths = []
motif_steps = generator.solo.motifs.interval_steps(reference, harmony)
gesture_start = cell.gesture.start if cell.gesture else cell.start
heard = tuple(n for n in state.notes if n.start >= gesture_start)
cursor = len(heard)
if motif_steps and last:
step, semitones = motif_steps[max(0, cursor - 1) % len(motif_steps)]
try:
expected = generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, last.pitch.number()) + step).number() if step is not None else last.pitch.number() + semitones
except ValueError:
expected = last.pitch.number() + semitones
supported = min(available, key=lambda p: (abs(p.number() - expected), p))
if supported not in starts:
starts.append(supported)
if motif_steps:
motif_steps = motif_steps[cursor % len(motif_steps):] + motif_steps[:cursor % len(motif_steps)]
for first in starts:
displacement = generator.solo.types.scale_index(scale, target.number()) - generator.solo.types.scale_index(scale, first.number())
if next_anchor and next_anchor.earliest > cell.end:
displacement = round(displacement * float((cell.end - cell.start) / (next_anchor.earliest - cell.start)))
direction = (displacement > 0) - (displacement < 0)
routes = [(motif_steps, 'develop ' + reference.id)]
retrieved = sequences.retrieve(len(spans), displacement, direction)
selected = generator.solo.sequences.diverse(retrieved, lambda p: (abs(p.offsets()[-1] - displacement), p.sources), lambda p: generator.solo.sequences.family(p.steps), 4)
for path in selected:
steps = tuple((step, generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, first.number()) + step).number() - first.number()) for step in path.steps)
if motif_steps and treatment != 'rhythmic_reference' and not (cell.gesture and cell.gesture.relationship == 'close'):
# Preserve identifying motion, then let the authored vocabulary
# connect it toward the next anchor. The next cell uses this result.
keep = min(2, len(steps))
steps = tuple(motif_steps[i % len(motif_steps)] for i in range(keep)) + steps[keep:]
routes.append((steps, 'sequences ' + ' + '.join(path.sources) + '; develop ' + reference.id))
for steps, name in routes:
notes = realize_route(spans, first, steps, forced, choices, plan, harmony, contracts, reference)
if notes:
paths.append((notes, name))
if last and last.end == cell.start and spans[0][0] == cell.start and not protected:
# An existing sustain is a real alternative, independent of reattack.
for notes, name in list(paths):
if notes[0].pitch.number() == last.pitch.number() and last.role != 'quote':
paths.append(((dataclasses.replace(notes[0], pitch=last.pitch, continuation=True, role='sustain'), *notes[1:]), 'common_tone; ' + name))
# Plan a prepared suspension from the incoming sound before constructing
# its continuation. The contract survives subsequent search boundaries.
if last and last.role != 'quote' and last.end == cell.start and cell.end - cell.start >= 1 and last.start <= cell.start - fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_preparation)):
span = harmony.at(cell.start)
prepared = last.pitch.number() % 12 in harmony.members(cell.start - fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_preparation)), cell.start)
if prepared and span and span.scale and last.pitch.number() % 12 in span.scale.pitches and last.pitch.number() % 12 not in span.roles.basic:
resolution_time = cell.start + fractions.Fraction(1, 2)
resolving = [p for p in options(choices, resolution_time, min(cell.end, resolution_time + fractions.Fraction(1, 2)), plan.register) if p.number() % 12 in span.roles.basic and harmony.step(last.pitch.number(), p.number(), resolution_time)]
for pitch in resolving:
contract = generator.solo.types.HarmonicTreatment(f'{plan.passage_id}_suspension_{cell.start}_{pitch.number()}', 'suspension', last.pitch.number(), cell.start, resolution_time, pitch.number(), cell.start + fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_resolution)))
for original, name in paths[:4]:
tail = tuple(n for n in original if n.start > resolution_time)
end = tail[0].start if tail else cell.end
suspended = (generator.solo.types.Note(cell.start, resolution_time, last.pitch, role='sustain', continuation=True), generator.solo.types.Note(resolution_time, end, pitch, role='motif'), *tail)
raw = tuple(dataclasses.replace(n, position=None) for n in state.notes)
if not raw:
continue
combined = generator.solo.types.sounding_notes((*raw, *suspended))
checked = harmony.evaluate(combined, (*contracts, contract), until=cell.end)
if checked.valid:
features = evaluation.features(tuple(dataclasses.replace(n, continuation=False) for n in suspended))
features['harmonic_exposure'] = checked.exposure
fidelity = generator.solo.scoring.motif_fidelity(tuple(n for n in combined if n.start >= (cell.gesture.start if cell.gesture else cell.start)), reference, treatment)
features['fidelity'] = fidelity
result.append(Candidate(suspended, identifier + '; prepared_suspension; ' + name, treatment, (generator.solo.scoring.value(features),), fidelity, (*contracts, contract)))
for notes, name in paths:
# Continuations are canonicalized with their incoming sounding event.
raw = tuple(dataclasses.replace(n, position=None) for n in state.notes)
combined = generator.solo.types.sounding_notes((*raw, *notes)) if raw or not notes[0].continuation else ()
if not combined:
continue
harmonic = harmony.evaluate(combined, contracts, until=cell.end)
if not harmonic.valid:
continue
local = tuple(dataclasses.replace(n, continuation=False) for n in notes)
features = evaluation.features(local)
gesture_notes = tuple(n for n in combined if n.start >= (cell.gesture.start if cell.gesture else cell.start))
fidelity = (generator.solo.scoring.motif_fidelity(gesture_notes, reference, treatment) if len(gesture_notes) > 1 else 0.5) if cell.gesture or cell.motif else 0
features['fidelity'] = fidelity
features['harmonic_exposure'] = harmonic.exposure
features['connection'] = min(1, abs(notes[0].pitch.number() - last.pitch.number()) / 12) if last else 0
result.append(Candidate(notes, identifier + '; ' + name, name if protected else treatment, (generator.solo.scoring.value(features),), fidelity, contracts))
unique = {}
for candidate in sorted(result, key=lambda c: (c.rank, c.source, tuple(n.pitch for n in c.notes))):
unique.setdefault((candidate.notes, candidate.treatments), candidate)
return list(unique.values())
@dataclasses.dataclass
class SearchContext:
pools: dict = dataclasses.field(default_factory=dict)
results: dict = dataclasses.field(default_factory=dict)
resources: dict = dataclasses.field(default_factory=dict)
revise: bool = True
batch: int = 0
references: tuple = ()
stage: str = 'elaborated'
diagnostics: dict = dataclasses.field(default_factory=lambda: {'searches': 0, 'cache_hits': 0, 'generated_candidates': 0, 'evaluated_extensions': 0, 'peak_states': 0, 'candidate_families': {}, 'surviving_families': {}})
def note_family(notes):
return generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))) if notes else 'space'
def phrase_context(plan, incoming, notes):
return generator.solo.drafts.canonical_context(notes, incoming, plan)
def evaluation_plan(plan, anchors):
landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, a.pitch.number(), a.pitch.number(), name=a.role) for a in anchors)
return dataclasses.replace(plan, start=plan.passage_start if plan.passage_start is not None else plan.start, end=plan.passage_end or plan.end, landmarks=landmarks)
def inherited_treatments(plan, incoming):
if plan.passage_id and plan.passage_id == incoming.passage_id:
return incoming.treatments
retained = []
for treatment in incoming.treatments:
future = treatment.start >= plan.start
color = treatment.kind == 'color' and incoming.last and treatment.end > incoming.last.start
pending = treatment.kind == 'suspension' and incoming.last and incoming.last.pitch.number() == treatment.pitch and incoming.last.end > treatment.start and not any(n.start >= treatment.start and n.pitch.number() == treatment.resolution for n in incoming.notes)
if future or color or pending:
retained.append(treatment)
# Completed contracts remain in their original phrase reports; only the
# sounding tail and unresolved obligations require context in a new passage.
return tuple(retained)
def draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, context):
"""Instantiate a complete timed line. Boundaries here are musical groups."""
F = fractions.Fraction
contracts = tuple(dict.fromkeys((*inherited_treatments(plan, incoming), *plan.treatments, *anchor_treatments(rhythm.anchors, harmony))))
# Cost, actual pitches so far, and the identity of the introduced statement.
states = [(0.0, (), rhythm.reference.notes if isinstance(rhythm.reference, generator.solo.types.RealizedMotif) else ())]
next_anchor = [next((a for a in rhythm.anchors if a.latest >= s.start), None) for s in rhythm.slots]
for index, slot in enumerate(rhythm.slots):
available = options(choices, slot.start, slot.end, plan.register)
pending = next((t for t in contracts if t.kind == 'suspension' and t.deadline is not None and incoming.last and incoming.last.pitch.number() == t.pitch and incoming.last.end == slot.start and t.start <= slot.start <= t.deadline and not any(n.start >= t.start and n.pitch.number() == t.resolution for n in incoming.notes)), None)
if pending:
available = tuple(p for p in available if p.number() == pending.resolution)
if slot.forced:
available = tuple(p for p in available if p == slot.forced)
quoted = slot.protected and plan.treatment in ('quote', 'exact_quote')
if not quoted and slot.end - slot.start >= generator.solo.policy.DEFAULT.long_note:
members = harmony.members(slot.start, slot.end)
available = tuple(p for p in available if p.number() % 12 in members or any(t.pitch == p.number() and t.start <= slot.start and t.end >= slot.end for t in contracts))
if not available:
return (), f'{slot.id}: no valid pitch for sounding interval {slot.start}-{slot.end}'
following = []
group = rhythm.groups[slot.group]
local_index = sum(s.group == slot.group for s in rhythm.slots[:index])
remaining = sum(s.start >= slot.start and (not next_anchor[index] or s.start <= next_anchor[index].latest) for s in rhythm.slots[index:])
span = harmony.at(slot.start)
for cost, notes, home in states:
prior = notes[-1] if notes else incoming.last
reference = home if home and slot.group else rhythm.reference.notes
if local_index >= 3 and group.operation in ('extend', 'extend_diminished', 'answer', 'continue'):
previous_ids = {s.id for s in rhythm.slots if s.group == slot.group - 1}
recent = tuple(n for n in notes if n.event_id in previous_ids) if slot.group else incoming.development.notes if incoming.development else ()
reference = recent or reference
contour = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(reference, reference[1:]))
step = contour[(max(0, slot.reference - 1)) % len(contour)] if contour else 0
if slot.reference == 0 and local_index == 0:
# A group continues the incoming line; it does not teleport to the
# source motif's register or to the center of a fresh bar.
step = contour[-1] if contour and group.operation == 'extend' else 0
expected = prior.pitch.number() + step if prior else rhythm.reference.notes[0].pitch.number()
anchor = next_anchor[index]
target = anchor.pitch.number() if anchor else expected
# Theme invention follows a complete paired rhythmic/interval idea.
# Authored sequence routes are instantiated as whole alternatives later.
route = expected if not anchor else target
ranked = sorted(available, key=lambda p: (abs(p.number() - expected), p))[:3]
if available:
bridge = min(available, key=lambda p: (abs(p.number() - route), p))
if bridge not in ranked:
ranked.append(bridge)
for pitch in ranked:
direction = (pitch.number() > prior.pitch.number()) - (pitch.number() < prior.pitch.number()) if prior else 0
intended = (step > 0) - (step < 0)
identifying = bool(local_index in (1, 2) and group.operation not in ('close', 'answer') and not slot.forced)
jump = abs(pitch.number() - prior.pitch.number()) if prior else 0
stopping = slot.end - slot.start >= generator.solo.policy.DEFAULT.stopping_note
motion_cost = abs(pitch.number() - expected) / 12 * (1.5 if identifying else 0.35)
direction_cost = max(0, abs(pitch.number() - target) - 2 * max(0, remaining - 1)) / 12 if anchor else 0
exposure = 0.6 if stopping and span and pitch.number() % 12 not in span.roles.basic and not quoted else 0
supported_leap = reference and abs(step) >= 3 and abs(pitch.number() - expected) <= 2
score = cost + motion_cost + direction_cost + (0 if supported_leap else max(0, jump - 5) / 12) + exposure
sources = reference[min(slot.reference, len(reference) - 1)].sources if reference else ()
note = generator.solo.types.Note(slot.start, slot.end, pitch, sources, 'quote' if quoted else 'motif', event_id=slot.id)
path = (*notes, note)
identity = home
if not identity and (index + 1 == len(rhythm.slots) or rhythm.slots[index + 1].group != slot.group):
identity = tuple(n for n in path if n.event_id in {s.id for s in rhythm.slots if s.group == slot.group})
following.append((score, path, identity))
if not following:
return (), f'{group.id}: identifying motion cannot reach the selected anchor within the draft budget'
boundary = index + 1 == len(rhythm.slots) or rhythm.slots[index + 1].group != slot.group
limit = generator.solo.policy.DEFAULT.draft_frontier if boundary else BEAM_WIDTH
# Relevant home/contour alternatives survive; no barline participates.
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], tuple(n.pitch for n in item[1])), lambda item: (tuple(n.pitch for n in item[2][:3]), item[1][-1].pitch), limit))
context.diagnostics['evaluated_extensions'] += len(following)
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
return tuple((notes, cost / max(1, len(notes)), contracts) for cost, notes, _ in states[:4]), ''
def make_identity(identifier, parent, notes, start, end, relationship, harmony):
local = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start, position=None) for n in notes)
degrees = tuple(dict(harmony.at(n.start).roles.degrees).get(n.pitch.number() % 12, ()) for n in notes)
return generator.solo.types.RealizedMotif(identifier, parent, local, end - start, relationship, 'developed_rhythm', degrees, start)
def make_draft(rhythm, notes, plan, harmony, incoming):
transforms, structural, protected = [], [], []
home = incoming.home if rhythm.relationship not in ('statement', 'contrast') else None
if rhythm.relationship == 'recall' and isinstance(rhythm.reference, generator.solo.types.RealizedMotif):
home = rhythm.reference
parent = incoming.development.id if incoming.development and home else rhythm.reference.id
for index, group in enumerate(rhythm.groups):
ids = {s.id for s in rhythm.slots if s.group == index}
actual = tuple(n for n in notes if n.event_id in ids)
if not actual:
continue
next_start = rhythm.groups[index + 1].start if index + 1 < len(rhythm.groups) else group.end
start, end = group.start, max(min(group.end, next_start), actual[-1].end)
identity = make_identity(group.id, parent, actual, start, end, group.operation, harmony)
if home is None:
home = identity
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(actual, actual[1:]))
# Closing simplification explicitly releases the characteristic core.
preserves = ()
if plan.treatment == 'rhythmic_reference':
preserves = ('rhythm',)
transforms.append(generator.solo.types.Transformation(group.id, parent, home.id, group.operation, start, end, tuple(n.event_id for n in actual), tuple((n.start - start, n.end - start) for n in actual), directions, preserves, group.source))
parent = identity.id
protected.extend(s.id for s in rhythm.slots if s.protected)
for anchor in rhythm.anchors:
note = generator.solo.harmonic.anchor_note(anchor, notes)
if note:
structural.append(note.event_id)
joins = tuple(t.start for t in transforms[1:])
return generator.solo.types.MelodicDraft(rhythm.id, plan.start, plan.end, notes, tuple(transforms), tuple(dict.fromkeys(structural)), tuple(protected), rhythm.anchors, home, rhythm.relationship, joins, reference=rhythm.reference.id, reference_motif=rhythm.reference)
def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edits=()):
valid, _ = generator.solo.drafts.contract_valid(draft, notes)
if not valid:
return None
placed = generator.solo.placement.place(notes, tempo, incoming.last, harmony.profile)
if placed is None:
return None
travel, notes = placed
try:
canonical = phrase_context(plan, incoming, notes)
except ValueError:
return None
complete = plan.end == (plan.passage_end or plan.end)
checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete, enclosures=draft.enclosures, expressions=draft.expressions)
valid, _ = generator.solo.harmonic.anchors_valid(draft.anchors, canonical, plan.end, complete=complete, closure=plan.closure)
if not checked.valid or not valid or not target_ok(notes, plan.target):
return None
shared = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
score_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
musical = None
if plan.treatment == 'thematic_recomposition':
musical = generator.solo.scoring.musical(plan,draft,notes,incoming,harmony,travel=travel)
if not musical.valid:return None
score = musical.total
else:
evaluation_key = (plan, draft.anchors)
if evaluation_key not in harmony.evaluations:
harmony.evaluations[evaluation_key] = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end)
evaluation = harmony.evaluations[evaluation_key]
if not evaluation.feasible(score_notes):
return None
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0))
features.update(generator.solo.drafts.relationship_features(draft, notes))
if draft.lead_identity or draft.source_phrase:
supported = {e for feature in draft.features for e in feature.events}
motions = [(a, b) for a, b in zip(notes, notes[1:]) if not (a.event_id in supported and b.event_id in supported)]
features['contour'] = sum(min(1, max(0, abs(b.pitch.number() - a.pitch.number()) - 5) / 12) for a, b in motions) / max(1, len(motions))
# Fidelity is checked against explicit transformed identities, not against
# the source head as one undifferentiated phrase-long quotation.
features.pop('fidelity', None)
features['harmonic_exposure'] = checked.exposure
intended_repeats = 0
if draft.home:
reference = draft.home.notes
for transform in draft.transformations:
actual = tuple(n for n in notes if n.event_id in transform.events)
for i, (a, b, c) in enumerate(zip(actual, actual[1:], actual[2:])):
expected = tuple(reference[j % len(reference)].pitch for j in (i, i + 1, i + 2)) if reference else ()
if a.pitch == b.pitch == c.pitch and len(expected) == 3 and expected[0] == expected[1] == expected[2]:
intended_repeats += 1
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(score_notes) - 2))
score = generator.solo.scoring.value(features)
motifs = list(incoming.motifs) if shared else []
decisions = []
for transform in draft.transformations:
actual = tuple(n for n in notes if n.event_id in transform.events or transform.start <= n.start < transform.end and n.event_id not in {e for t in draft.transformations for e in t.events})
if not actual:
continue
identity = make_identity(transform.id, transform.parent, actual, transform.start, transform.end, transform.operation, harmony)
motifs.append(identity)
treatment = f'quote octave {round((actual[0].pitch.number() - plan.motif.notes[0].pitch.number()) / 12):+}' if actual[0].role == 'quote' else transform.operation
decisions.append(generator.solo.types.Decision(min(n.start for n in actual), max(n.end for n in actual), 'phrase draft ' + draft.id + '; ' + transform.operation, treatment, actual, 0, transform.id, transform.parent))
# Output ordering follows absolute events, even when a transformation's tail
# was reopened by an overlapping extension.
decisions.sort(key=lambda d: d.start)
emitted = tuple(n for d in decisions for n in d.notes)
if emitted != notes:
return None
home = next((m for m in motifs if draft.home and m.id == draft.home.id), draft.home)
themes = tuple(dict.fromkeys((*incoming.themes, *((home,) if home else ()))))
state_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
last = state_notes[-1] if state_notes else incoming.last
state = generator.solo.types.ContinuationState(plan.passage_id, state_notes, tuple(motifs), contracts,
tuple(a.id for a in draft.anchors if generator.solo.harmonic.anchor_note(a, canonical)),
(notes[-1].pitch.number() > notes[0].pitch.number()) - (notes[-1].pitch.number() < notes[0].pitch.number()) if notes else incoming.direction,
last, 0, (), travel + (incoming.travel if shared else 0), home, motifs[-1] if motifs else incoming.development, themes, draft.anchors, plan.ending)
actual_plan = dataclasses.replace(plan, relationship=draft.relationship)
return generator.solo.types.Phrase(actual_plan, tuple(decisions), score, state, draft.anchors, draft, edits, evaluation=musical)
def legacy_target_plan(plan, choices):
if not plan.target or plan.anchor_options:
return plan
target = plan.target
duration = fractions.Fraction(1, 2)
arrival = next((p for p in plan.passages if p.start <= target.earliest < p.end and p.intent == 'sustained_arrival'), None)
if arrival and arrival.activity <= fractions.Fraction(1, 4):
duration = arrival.end - target.earliest
pitches = options(choices, target.earliest, target.earliest + duration, plan.register)
anchors = tuple((generator.solo.types.Anchor(plan.id + '_target', target.earliest, target.latest, pitch, target.pitches, plan.register, duration, 'terminal', target.chord, True, target.earliest + duration),) for pitch in pitches if pitch.number() % 12 in target.pitches)
return dataclasses.replace(plan, anchor_options=anchors)
def emitted_notes(phrase):
return tuple(n for d in phrase.decisions for n in d.notes)
def window_variants(phrase, low, high, harmony, sequences, kind):
"""Two fixed outside endpoints bound edits to the existing melody."""
notes = emitted_notes(phrase)
protected = set((*phrase.draft.structural, *phrase.draft.protected, *(e for f in phrase.draft.features for e in f.events), *(n.event_id for r in phrase.routes for n in r.notes)))
inside = [i for i, n in enumerate(notes) if low <= n.start and n.end <= high]
editable = [i for i in inside if notes[i].event_id not in protected]
if not editable:
return ()
candidates = []
left, right = max(0, inside[0] - 1), min(len(notes) - 1, inside[-1] + 1)
# Compare complete routes through both fixed endpoints. Reject an invalid
# path as a unit; never substitute nearest pitches for individual steps.
for route in generator.solo.routes.candidates(notes[left:right + 1], protected, harmony, sequences, phrase.plan.register):
candidates.append(((*notes[:left], *route.notes, *notes[right + 1:]), 'complete route ' + ' + '.join(route.sources)))
for index in editable:
note = notes[index]
if index + 1 < len(notes) and notes[index + 1].start <= high:
after = notes[index + 1]
if after.event_id not in protected and note.end == after.start and note.pitch == after.pitch:
candidates.append(((*notes[:index], dataclasses.replace(note, end=after.end), *notes[index + 2:]), 'shared common-tone arrival'))
if kind == 'join' and after.event_id not in protected and note.end == after.start and note.end - note.start > fractions.Fraction(1, 2):
# The tail and pickup move together, preserving total sounding time.
time = after.start - fractions.Fraction(1, 2)
candidates.append(((*notes[:index], dataclasses.replace(note, end=time), dataclasses.replace(after, start=time), *notes[index + 2:]), 'reopen tail into pickup'))
return tuple(candidates)
def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind, context):
original = emitted_notes(phrase)
viable = [phrase]
proposals = window_variants(phrase, low, high, harmony, sequences, kind)
# Keep distinct rhythmic and pitch revisions; no generic unlimited search.
proposals = generator.solo.sequences.diverse(proposals, lambda p: (abs(len(p[0]) - len(original)), p[1], tuple(n.pitch for n in p[0])), lambda p: (p[1].split()[0], len(p[0])), generator.solo.policy.DEFAULT.join_alternatives)
for notes, source in proposals:
if tuple((n.start, n.end, n.pitch, n.continuation) for n in notes) == tuple((n.start, n.end, n.pitch, n.continuation) for n in original):
continue
changed_before = tuple(n.event_id for n in original if n not in notes)
changed_after = tuple(n.event_id for n in notes if n not in original)
edit = generator.solo.types.DraftEdit(kind, low, high, changed_before, changed_after, source)
revised = checked_phrase(phrase.plan, phrase.draft, notes, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
context.diagnostics[kind + '_attempts'] = context.diagnostics.get(kind + '_attempts', 0) + 1
if revised:
viable.append(dataclasses.replace(revised, initial=phrase.initial, refined=phrase.refined, routes=phrase.routes))
chosen = min(viable, key=lambda p: (p.score, len(p.edits), tuple((n.pitch, n.start, n.end) for n in emitted_notes(p))))
if chosen is not phrase:
context.diagnostics[kind + '_accepted'] = context.diagnostics.get(kind + '_accepted', 0) + 1
return chosen
def rebase_draft(draft, notes, harmony):
"""Reassign edited events by absolute group time, retaining source contracts."""
notes = tuple(dataclasses.replace(n, position=None) for n in notes)
groups = []
original_groups = {g.id: g for g in draft.transformations}
for i, group in enumerate(draft.transformations):
low = draft.start if i == 0 else group.start
high = draft.transformations[i + 1].start if i + 1 < len(draft.transformations) else draft.end
actual = tuple(n for n in notes if low <= n.start < high)
if actual:
parent = group.parent
while parent in original_groups and parent not in {g.id for g in groups}:
parent = original_groups[parent].parent
groups.append(dataclasses.replace(group, parent=parent, events=tuple(n.event_id for n in actual), rhythm=group.rhythm if group.preserves else tuple((n.start - group.start, n.end - group.start) for n in actual)))
home = draft.home
if home and groups and home.id == groups[0].id:
actual = tuple(n for n in notes if n.event_id in groups[0].events)
home = make_identity(home.id, home.parent, actual, groups[0].start, groups[0].end, home.relationship, harmony)
return dataclasses.replace(draft, notes=notes, transformations=tuple(groups), home=home)
def replace_idea(phrase, donor, low, high, incoming, harmony, tempo):
"""A whole two-bar idea is editable until its identifying features are selected."""
original = emitted_notes(phrase)
if any(n.start < low < n.end or n.start < high < n.end for n in original):
return None
replacement = tuple(n for n in emitted_notes(donor) if low <= n.start and n.end <= high)
if not replacement or tuple((n.start, n.end, n.pitch) for n in replacement) == tuple((n.start, n.end, n.pitch) for n in original if low <= n.start < high):
return None
protected = set((*phrase.draft.structural, *phrase.draft.protected))
identifiers = {}
for before in original:
if before.event_id in protected and low <= before.start < high:
after = next((n for n in replacement if (n.start, n.end, n.pitch) == (before.start, before.end, before.pitch)), None)
if not after:
return None
identifiers[after.event_id] = before.event_id
replacement = tuple(dataclasses.replace(n, event_id=identifiers.get(n.event_id, n.event_id + '_replacement'), position=None) for n in replacement)
notes = tuple(n for n in original if n.end <= low) + replacement + tuple(n for n in original if n.start >= high)
valid, _ = generator.solo.drafts.contract_valid(phrase.draft, notes)
if not valid:
return None
draft = rebase_draft(phrase.draft, notes, harmony)
edit = generator.solo.types.DraftEdit('replace_idea', low, high, tuple(n.event_id for n in original if low <= n.start < high), tuple(n.event_id for n in replacement), donor.draft.reference + '; ' + donor.draft.id)
return checked_phrase(phrase.plan, draft, notes, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
def refine(phrase, pool, incoming, harmony, sequences, tempo, context, search=True):
initial = phrase.draft
for low in (phrase.plan.start, phrase.plan.start + 8) if search else ():
high = min(phrase.plan.end, low + 8)
if high - low < 8 or phrase.draft.protected:
continue
viable = [phrase]
for donor in pool:
if donor.draft.id == phrase.draft.id:
continue
context.diagnostics['idea_replacements_tried'] = context.diagnostics.get('idea_replacements_tried', 0) + 1
revised = replace_idea(phrase, donor, low, high, incoming, harmony, tempo)
if revised:
viable.append(revised)
chosen = min(viable, key=lambda p: (p.score, len(p.edits), p.draft.id))
if chosen is not phrase:
context.diagnostics['idea_replacements_selected'] = context.diagnostics.get('idea_replacements_selected', 0) + 1
phrase = chosen
if search and context.revise:
phrase = revise_joins(phrase, incoming, harmony, sequences, tempo, context)
notes = emitted_notes(phrase)
draft = rebase_draft(phrase.draft, notes, harmony)
# Only now commit a selected, inspectable relationship for elaboration.
features = generator.solo.features.identifying_features(notes, draft.reference)
draft = dataclasses.replace(draft, features=features)
# Rebuild lineage after group removal or replacement, including when no run
# is selected afterward. The introduced home is the actual refined tune.
rebuilt = checked_phrase(phrase.plan, draft, notes, phrase.state.treatments, incoming, harmony, tempo, phrase.edits)
if rebuilt is None:
raise ValueError(phrase.plan.id + ': refined theme lost a committed contract')
return dataclasses.replace(rebuilt, initial=initial, refined=draft)
def elaborate(phrase, incoming, harmony, sequences, tempo, context):
started = time.monotonic()
protected = set((*phrase.draft.structural, *phrase.draft.protected, *(e for f in phrase.draft.features for e in f.events)))
if phrase.draft.protected:
return phrase
# Elaborate selected approaches in the existing tune, at most two per phrase.
notes = emitted_notes(phrase)
windows = []
for right in range(2, len(notes)):
if notes[right].event_id not in protected and right != len(notes) - 1:
continue
left = next((i for i in range(right - 1, -1, -1) if notes[right].start - notes[i].start >= 4), None)
if left is not None and notes[right].start - notes[left].start <= 4:
windows.append((notes[left].start, notes[right].start))
limit = max(1, min(2, int((phrase.plan.end - phrase.plan.start) / 16))) if phrase.draft.lead_identity else 2
if phrase.draft.lead_identity:
intended = {phrase.plan.start + a for reference in context.references for a, _ in reference.runs}
windows.sort(key=lambda window: (sum(n.event_id in protected for n in notes if window[0] <= n.start < window[1]), window[0] not in intended, window))
selected_windows = windows[:limit]
else:
selected_windows = windows[-limit:]
for low, high in selected_windows:
if any(low < r.destination.end and high > r.notes[0].start for r in phrase.routes):
continue
notes = emitted_notes(phrase)
left = next(i for i, n in enumerate(notes) if n.start == low)
right = next(i for i, n in enumerate(notes) if n.start == high)
patterns = [notes[left:right + 1]]
# A full approach rhythm, preserving every committed event. Existing
# silence remains a candidate; short-note motion is never compulsory.
moving = []
timepoint = low
while timepoint < high:
original = next((n for n in notes[left:right] if n.start == timepoint), None)
end = min(high, timepoint + fractions.Fraction(1, 2))
moving.append(dataclasses.replace(original, end=end, position=None) if original else generator.solo.types.Note(timepoint, end, notes[left].pitch, role='run', event_id=f'route_{phrase.draft.id}_{timepoint}'))
timepoint = end
patterns.append((*moving, notes[right]))
for reference in context.references:
for a, b in reference.runs:
if high - low != b - a:
continue
profile = tuple(n for n in reference.decorated if a <= n.start and n.end <= b)
timed = []
for index, event in enumerate(profile):
start, end = low + event.start - a, low + event.end - a
original = next((n for n in notes[left:right] if n.start == start), None)
timed.append(dataclasses.replace(original, end=end, position=None) if original else generator.solo.types.Note(start, end, notes[left].pitch, (reference.id,), 'run', event_id=f'profile_{phrase.draft.id}_{start}'))
if timed:
patterns.append((*timed, notes[right]))
viable = [phrase]
for pattern in dict.fromkeys(patterns):
# Fixed events retain their duration and attack identity as well as pitch.
if any(not any((n.event_id, n.start, n.end) == (p.event_id, p.start, p.end) for p in pattern) for n in notes[left:right + 1] if n.event_id in protected):
continue
for route in generator.solo.routes.candidates(pattern, protected, harmony, sequences, phrase.plan.register, 'approach the theme destination'):
if phrase.draft.lead_identity:
# A run must actually contain connected eighth-note movement;
# relabeling the unchanged quarter-note answer is not elaboration.
run = peak = 0
previous_end = None
for note in route.notes:
eighth = note.end - note.start == fractions.Fraction(1, 2)
run = run + 1 if eighth and previous_end == note.start else int(eighth)
peak = max(peak, run)
previous_end = note.end
if peak < 4:
continue
actual = (*notes[:left], *route.notes, *notes[right + 1:])
if phrase.draft.lead_identity and len(actual) > generator.solo.policy.DEFAULT.lead_activity_range[1] * float(phrase.plan.end - phrase.plan.start):
continue
context.diagnostics['complete_routes_tried'] = context.diagnostics.get('complete_routes_tried', 0) + 1
if not generator.solo.drafts.contract_valid(phrase.draft, actual)[0]:
continue
draft = rebase_draft(phrase.draft, actual, harmony)
edit = generator.solo.types.DraftEdit('complete_route', low, notes[right].end, tuple(n.event_id for n in notes[left:right + 1]), tuple(n.event_id for n in route.notes), ' + '.join(route.sources))
revised = checked_phrase(phrase.plan, draft, actual, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
if revised:
# Retain the refined theme, not the ornamental version, as home.
state = dataclasses.replace(revised.state, home=phrase.state.home, themes=phrase.state.themes)
viable.append(dataclasses.replace(revised, state=state, initial=phrase.initial, refined=phrase.refined, routes=(*phrase.routes, route)))
else:
context.diagnostics['complete_routes_rejected'] = context.diagnostics.get('complete_routes_rejected', 0) + 1
# A selected approach is an explicit musical operation. Its complete valid
# routes compete with one another; a zero-edit movement advantage must not
# silently cancel every requested run. Failure retains the plain tune.
eligible = viable[1:] if phrase.draft.lead_identity and len(viable) > 1 else viable
chosen = min(eligible, key=lambda p: (p.score, len(p.edits), tuple(n.pitch for n in emitted_notes(p))))
if chosen is not phrase:
context.diagnostics['complete_routes_selected'] = context.diagnostics.get('complete_routes_selected', 0) + 1
phrase = chosen
context.diagnostics['elaboration_seconds'] = context.diagnostics.get('elaboration_seconds', 0) + time.monotonic() - started
return phrase
def revise_joins(phrase, incoming, harmony, sequences, tempo, context):
started = time.monotonic()
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
for _ in range(generator.solo.policy.DEFAULT.revision_sweeps):
before = phrase
notes = emitted_notes(phrase)
def weakness(join):
before = next((n for n in reversed(notes) if n.start < join), None)
after = next((n for n in notes if n.start >= join), None)
return (max(0, abs(after.pitch.number() - before.pitch.number()) - 4) + float(max(0, after.start - before.end))) if before and after else 0
joins = sorted(phrase.draft.joins, key=lambda j: (-weakness(j), j))[:generator.solo.policy.DEFAULT.revision_joins]
for join in joins:
phrase = improve_window(phrase, incoming, harmony, sequences, tempo, max(phrase.plan.start, join - width), min(phrase.plan.end, join + width), 'join', context)
if phrase == before:
break
context.diagnostics['revision_seconds'] = context.diagnostics.get('revision_seconds', 0) + time.monotonic() - started
return phrase
def revise_pair(first, second, incoming, choices, sequences, tempo, context):
"""Revise an uncommitted connected phrase seam, then rebuild both states."""
if first.routes or second.routes or first.refined or second.refined or not context.revise or not first.draft or not second.draft or first.plan.ending != 'connected' or first.plan.passage_id != second.plan.passage_id:
return first, second
incoming = incoming if isinstance(incoming, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=incoming)
harmony = generator.solo.harmonic.Harmony(choices)
seam = first.plan.end
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
combined = (*emitted_notes(first), *emitted_notes(second))
draft = dataclasses.replace(first.draft, end=second.plan.end, notes=(*first.draft.notes, *second.draft.notes), transformations=(*first.draft.transformations, *second.draft.transformations), structural=tuple(dict.fromkeys((*first.draft.structural, *second.draft.structural))), protected=(*first.draft.protected, *second.draft.protected), joins=(*first.draft.joins, seam, *second.draft.joins))
joint = dataclasses.replace(first, decisions=(generator.solo.types.Decision(first.plan.start, second.plan.end, 'provisional phrase pair', 'continue', combined),), draft=draft)
candidates = [(second.score + generator.solo.drafts.relationship_features(draft, combined)['join_coherence'], first, second)]
left_ids = {n.event_id for n in emitted_notes(first)}
for notes, source in window_variants(joint, seam - width, seam + width, harmony, sequences, 'join')[:generator.solo.policy.DEFAULT.join_alternatives]:
left = tuple(n for n in notes if n.event_id in left_ids)
right = tuple(n for n in notes if n.event_id not in left_ids)
if any(n.end > seam for n in left) or any(n.start < seam for n in right):
continue
edit = generator.solo.types.DraftEdit('phrase_join', seam - width, seam + width, tuple(n.event_id for n in combined if n not in notes), tuple(n.event_id for n in notes if n not in combined), source)
if not edit.removed:
continue
revised_left = checked_phrase(first.plan, first.draft, left, first.state.treatments, incoming, harmony, tempo, (*first.edits, edit))
if not revised_left:
continue
revised_right = checked_phrase(second.plan, second.draft, right, second.state.treatments, revised_left.state, harmony, tempo, (*second.edits, edit))
context.diagnostics['pair_revision_attempts'] = context.diagnostics.get('pair_revision_attempts', 0) + 1
if revised_right:
realized = (*emitted_notes(revised_left), *emitted_notes(revised_right))
score = revised_right.score + generator.solo.drafts.relationship_features(draft, realized)['join_coherence']
candidates.append((score, revised_left, revised_right))
_, left, right = min(candidates, key=lambda item: (item[0], len(item[1].edits) + len(item[2].edits)))
if left is not first:
context.diagnostics['pair_revisions_accepted'] = context.diagnostics.get('pair_revisions_accepted', 0) + 1
return left, right
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
context = context if context is not None else SearchContext()
if plan.treatment == 'lead':
raise ValueError('generic lead identities are retired; use composer.compose for source variations')
# Keep resources alive while identity-keyed cache entries exist. A later
# library cannot accidentally reuse the identity of a collected object.
context.resources[id(sequences)] = sequences
context.resources[id(rhythms)] = rhythms
pool_key = (plan, previous, generator.solo.policy.stable_rank('harmonic-cache', choices), tempo, id(sequences), id(rhythms), tuple(r.version for r in context.references))
key = (*pool_key, context.revise, context.stage)
if key in context.results:
context.diagnostics['cache_hits'] += 1
return context.results[key]
started = time.monotonic()
context.diagnostics['searches'] += 1
incoming = previous if isinstance(previous, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=previous)
harmony = generator.solo.harmonic.Harmony(choices)
plan = legacy_target_plan(plan, choices)
if pool_key in context.pools:
selected = context.pools[pool_key]
context.diagnostics['reused_draft_pools'] = context.diagnostics.get('reused_draft_pools', 0) + 1
else:
constructions = generator.solo.drafts.rhythms(plan, incoming, measures, choices, rhythms, context.batch, context.references)
complete, failure = [], ''
for rhythm in constructions:
candidate_plan = generator.solo.drafts.ending_plan(plan, rhythm.anchors)
paths, error = draft_pitch_paths(rhythm, candidate_plan, choices, harmony, sequences, incoming, context)
failure = error or failure
context.diagnostics['generated_candidates'] += len(paths)
for notes, cost, contracts in paths:
family = note_family(notes)
counts = context.diagnostics['candidate_families']
counts[family] = counts.get(family, 0) + 1
realizations = [notes]
if notes and incoming.last and incoming.last.end == notes[0].start and incoming.last.pitch == notes[0].pitch and notes[0].role != 'quote' and not any(a.onset_required and a.earliest <= notes[0].start <= a.latest for a in rhythm.anchors):
realizations.append((dataclasses.replace(notes[0], continuation=True), *notes[1:]))
for actual in realizations:
draft = make_draft(rhythm, actual, candidate_plan, harmony, incoming)
phrase = checked_phrase(candidate_plan, draft, actual, contracts, incoming, harmony, tempo)
if phrase:
complete.append(phrase)
if not complete:
raise SearchExhausted(f'{plan.id}: bounded phrase draft search exhausted; {failure or "no complete draft satisfies timing, anchors, treatments and motif contracts"}; {len(constructions)} constructions / {BEAM_WIDTH} pitch states (not proof that the phrase is unplayable)')
context.diagnostics['construction_seconds'] = context.diagnostics.get('construction_seconds', 0) + time.monotonic() - started
selected = generator.solo.sequences.diverse(complete, lambda p: (p.score, p.draft.id, tuple(n.pitch for n in p.draft.notes)), lambda p: (p.draft.relationship, note_family(p.draft.notes), p.anchors[-1].duration if p.anchors else None), generator.solo.policy.DEFAULT.phrase_alternatives)
context.pools[pool_key] = selected
input_id = generator.solo.policy.stable_rank(plan.seed, (plan, previous, choices))[:16]
pool_id = generator.solo.policy.stable_rank(plan.seed, tuple((p.draft.id, emitted_notes(p)) for p in selected))
context.diagnostics.setdefault('draft_pool_fingerprints', {})[input_id] = pool_id
result = []
for index, phrase in enumerate(selected):
if context.stage == 'initial':
phrase = dataclasses.replace(phrase, initial=phrase.draft)
else:
phrase = refine(phrase, selected, incoming, harmony, sequences, tempo, context, search=index < generator.solo.policy.DEFAULT.refinement_alternatives)
if context.stage == 'elaborated':
phrase = elaborate(phrase, incoming, harmony, sequences, tempo, context)
result.append(phrase)
result = tuple(sorted(result, key=lambda p: (p.score, p.draft.id)))
for phrase in result:
family = note_family(emitted_notes(phrase))
counts = context.diagnostics['surviving_families']
counts[family] = counts.get(family, 0) + 1
context.diagnostics.setdefault('complete_ideas', {})[input_id] = [{'id': p.draft.id, 'reference': p.draft.reference, 'score': round(p.score, 6), 'attacks': len(emitted_notes(p)), 'ending': p.plan.ending, 'hold': str(p.anchors[-1].duration) if p.anchors else '', 'edits': [e.kind for e in p.edits]} for p in result]
context.results[key] = result
return result
def compose(plan, measures, choices, sequences, rhythms, tempo, previous=None):
return alternatives(plan, measures, choices, sequences, rhythms, tempo, previous)[0]
-79
View File
@@ -1,79 +0,0 @@
"""Accepted, paired pitch/rhythm examples; production uses contextual profiles."""
from __future__ import annotations
import dataclasses
import fractions
import hashlib
import json
import pathlib
import generator.harmony
import generator.model
import generator.solo.types as types
F = fractions.Fraction
@dataclasses.dataclass(frozen=True)
class ReferencePhrase:
id: str
version: str
notes: tuple[types.Note, ...]
decorated: tuple[types.Note, ...]
duration: F
tonic: int
harmony: tuple[tuple[F, str], ...]
features: tuple[tuple[str, F, F], ...]
runs: tuple[tuple[F, F], ...]
acceptance: str
development: tuple[tuple[str, F, F, tuple[int, ...]], ...] = ()
def windows(self):
# Windows retain their parent and context; they are not separate approvals.
result = []
for length in (F(8), F(16)):
for start in range(0, int(self.duration), int(length)):
end = min(self.duration, start + length)
selected = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in self.notes if start <= n.start and n.end <= end)
if len(selected) >= 2:
chords = tuple(chord for time, chord in self.harmony if start <= time < end)
result.append(types.Motif(f'{self.id}:{start}:{end}', selected, end - start, chords, 'accepted reference profile'))
return tuple(result)
def read(path):
raw = path.read_bytes()
data = json.loads(raw)
if data['pitch_convention'] != 'sounding' or not data.get('acceptance'):
raise ValueError(f'{path}: reference requires sounding pitches and explicit acceptance provenance')
def notes(variant):
result, cursor = [], F()
for i, event in enumerate(data['variants'][variant]['events']):
start, duration = F(event['onset_beats']), F(event['duration_beats'])
if start != cursor or duration <= 0:
raise ValueError(f'{path}: reference events must fill exact sequential time')
cursor = start + duration
if event['pitch'] is None:
continue
name = event['pitch']
pitch = types.Pitch(name[0].lower(), F(1 if '#' in name else -1 if 'b' in name else 0), int(name[-1]))
if pitch.number() != event['midi_pitch']:
raise ValueError(f'{path}: inconsistent sounding pitch {name}')
result.append(types.Note(start, cursor, pitch, (data['id'],), event_id=f'{variant}_{i}'))
return tuple(result), cursor
plain, duration = notes('lead_theme')
decorated, other_duration = notes('lead_with_runs')
if duration != other_duration or len(plain) != data['variants']['lead_theme']['attacks'] or len(decorated) != data['variants']['lead_with_runs']['attacks']:
raise ValueError(f'{path}: inconsistent reference variants')
tonic = generator.model.parse_pitch_name(data['tonic'])
number = int(generator.model.NATURAL[tonic['letter']] + generator.model.alteration(tonic['alter']))
harmony = tuple((F(4 * (h['bar'] - 1)), h['chord']) for h in data['harmony'])
for _, chord in harmony:
generator.harmony.parse_symbol(chord)
return ReferencePhrase(data['id'], hashlib.sha256(raw).hexdigest(), plain, decorated, duration, number, harmony,
tuple((f['id'], F(f['start']), F(f['end'])) for f in data['features']), tuple((F(w['start']), F(w['end'])) for w in data['run_windows']), data['acceptance'],
tuple((d['role'], F(d['start']), F(d['end']), tuple(d.get('degrees', ()))) for d in data.get('development', ())))
def load_library(root: pathlib.Path):
return tuple(read(path) for path in sorted(root.glob('*.json')))
from generator.solo.features import identifying_features, feature_error, relationship
+37 -176
View File
@@ -1,185 +1,46 @@
from __future__ import annotations
import dataclasses
import json
import fractions
import generator.harmony
import generator.model
import generator.scales.report
import generator.scales.selection
import generator.scales.timeline
import generator.solo.types
import generator.solo.harmonic
import generator.solo.drafts
import generator.solo.similarity
import generator.solo.features
"""Compact reports of actual selected phrases, not duplicated draft ancestry."""
import dataclasses, fractions, json
import generator.solo.placement
from generator.solo import harmonic
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0, *, detail=None) -> str:
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
positioned = tuple(dataclasses.replace(c, span=dataclasses.replace(c.span, start=c.span.start + time_offset, end=c.span.end + time_offset, measure=c.span.measure + measure_offset)) for c in solo.choices)
lines = [generator.scales.report.passage(solo.part, region, positioned, scales), f'Phrase composition: {solo.part}', f'Placement reference tempo: {solo.tempo} quarter notes/minute. Runtime tempo changes are not a comfort guarantee.', 'Times below are quarter-note offsets from the start of this solo; pitches are sounding.']
if solo.diagnostics:
lines.append('Composition diagnostics: ' + json.dumps({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}, sort_keys=True,default=str))
detail = structure(solo) if detail is None else detail
lines.append("Sustained phrasing: " + json.dumps(detail["phrasing"], sort_keys=True))
for passage in detail['passages']:
lines.append(f"Melodic passage {passage['id']}: {passage['start']}-{passage['end']}; phrases {', '.join(passage['phrases'])}")
lines.append(f"Harmonic exposure: {detail['harmonic_exposure']:.4f}; unresolved treatments: {len(detail['unresolved_treatments'])}")
for phrase in solo.phrases:
plan = phrase.plan
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}')
if plan.treatment in ('source_variation', 'thematic_recomposition'):
lines.append(' Activity follows the selected gesture and tempo-aware vocabulary; occupancy is diagnostic, not a target.')
if phrase.draft:
draft = phrase.draft
lines.append(f' Complete draft {draft.id}: {draft.relationship}; home {draft.home.id if draft.home else "none"}; {len(draft.notes)} attacks; rests {draft.rests()}')
lines.append(f' Lead reference: {draft.reference}; selected features: ' + ', '.join(f.id for f in draft.features))
if draft.source_phrase:
lines.append(f' Source phrase {draft.source_phrase.id}; octave transfer {draft.octave_transfer:+}; source rests {draft.source_phrase.rests}; source-event links {draft.source_links}')
if draft.source_reference:
ref=draft.source_reference
lines.append(f' Source influence {ref.id}: {ref.treatment}; source {ref.source_id}, beats {ref.start}-{ref.end}. Later developments refer to solo theme {phrase.state.home.id}.')
lines.append(' Retained contracts: '+', '.join(f'{f.id}: {f.preserves}' for f in draft.features))
for enclosure in draft.enclosures:
lines.append(f' Enclosure {enclosure.id}: {enclosure.upper.pitch.label()} → {enclosure.lower.pitch.label()} → {enclosure.target.pitch.label()}; {enclosure.target_role} over {enclosure.chord}; arrival {enclosure.target.start}; protected through {enclosure.target.end}')
for route in phrase.routes:
lines.append(f' Complete route {route.id}: {route.notes[0].start}-{route.destination.start}; destination {route.destination.pitch.label()}; {route.relationship}; sources {route.sources}')
for transform in draft.transformations:
lines.append(f' Development {transform.id}: {transform.operation}; {transform.start}-{transform.end}; parent {transform.parent}; home {transform.home}; preserves {transform.preserves}')
for edit in phrase.edits:
lines.append(f' Revision {edit.kind}: {edit.start}-{edit.end}; {edit.source}; replaced {edit.removed}; realized {edit.added}')
for passage in plan.passages:
lines.append(f' Activity {passage.start}-{passage.end}: {passage.intent}; attacks/beat {float(passage.activity):.3f}; occupancy {float(passage.occupancy):.2f}')
for landmark in plan.landmarks:
lines.append(f' Landmark {landmark.name}: {landmark.earliest}-{landmark.latest}; sounding MIDI {landmark.low}-{landmark.high}; direction {landmark.direction}')
if plan.target:
lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}')
for anchor in phrase.anchors:
actual = generator.solo.harmonic.anchor_note(anchor, phrase.state.notes if phrase.state else solo.notes())
lines.append(f' Anchor {anchor.id}: {anchor.role}/{anchor.effect}; {anchor.chord}; {anchor.pitch.label()}; window {anchor.earliest}-{anchor.latest}; hold {anchor.duration}; hold until {anchor.hold_until}; completed {actual is not None}')
if phrase.state:
valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=phrase.plan.end == phrase.plan.passage_end, closure=phrase.plan.closure)
lines.append(f' Ending contract fulfilled: {valid}; {reason}')
for motif in phrase.state.motifs:
if phrase.plan.start <= motif.source_start < phrase.plan.end:
lines.append(f' Realized motif {motif.id}: parent {motif.parent}; {motif.relationship}; {motif.treatment}; pitches ' + ' '.join(n.pitch.label() for n in motif.notes) + f'; rhythm ' + ' '.join(f'{n.start}:{n.end}' for n in motif.notes))
for decision in phrase.decisions:
lines.append(f' {decision.start}-{decision.end}: {decision.source}; {decision.treatment}')
if not decision.notes:
lines.append(' Silence')
for note in decision.notes:
choice = next(c for c in solo.choices if c.span.start <= note.start < c.span.end)
root = generator.scales.selection.root_number(choice.span.chord)
degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12]
function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection'
if note.pitch.number()%12 not in generator.solo.types.permitted(solo.choices,note.start,note.end):
function='validated expression exception'
lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}')
for expression in detail['expressions']:
lines.append('Expression: ' + expression['kind'] + ' / ' + expression['rule'] + ' -> ' + str(expression['target']['pitch']))
lines.append('\nSounding harmonic spans (ties merged before classification):')
for event in detail['harmonic_roles']:
if float(fractions.Fraction(event['sounding_duration'])) >= 1 or event['protected'] or event['enclosure']:
lines.append(' ' + json.dumps(event, sort_keys=True))
return '\n'.join(lines) + '\n'
def plain(value):
if dataclasses.is_dataclass(value):return plain(dataclasses.asdict(value))
if isinstance(value,dict):return {k:plain(v) for k,v in value.items()}
if isinstance(value,(tuple,list,set,frozenset)):return [plain(v) for v in value]
if isinstance(value,fractions.Fraction):return str(value)
return value
def structure(solo):
harmony = generator.solo.harmonic.Harmony(solo.choices)
contracts = tuple(dict.fromkeys(t for p in solo.phrases if p.state for t in p.state.treatments))
enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures)
expressions = tuple(e for p in solo.phrases if p.draft for e in p.draft.expressions)
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures,expressions=expressions)
def rational(value):
if isinstance(value, dict):
return {key: rational(item) for key, item in value.items()}
if isinstance(value, (tuple, list, set, frozenset)):
return [rational(item) for item in value]
if isinstance(value, fractions.Fraction):
return str(value)
return value
phrases = []
for phrase in solo.phrases:
notes = phrase.state.notes if phrase.state else solo.notes()
complete = phrase.plan.end == (phrase.plan.passage_end or phrase.plan.end)
valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, notes, phrase.plan.end, complete=complete, closure=phrase.plan.closure)
anchors = []
for anchor in phrase.anchors:
event = generator.solo.harmonic.anchor_note(anchor, notes)
anchors.append({**rational(dataclasses.asdict(anchor)), 'pitch': anchor.pitch.label(), 'degrees': next((list(degrees) for pc,degrees in harmony.at(anchor.earliest).roles.degrees if pc==anchor.pitch.number()%12),[]), 'completed': event is not None, 'actual_start': str(event.start) if event else None, 'actual_end': str(event.end) if event else None})
motifs = tuple(m for m in phrase.state.motifs if phrase.plan.start <= m.source_start < phrase.plan.end) if phrase.state else ()
entry = {'id': phrase.plan.id, 'passage': phrase.plan.passage_id, 'start': str(phrase.plan.start), 'end': str(phrase.plan.end), 'ending': phrase.plan.ending, 'closure': phrase.plan.closure, 'ending_completed': valid, 'ending_error': reason, 'anchors': anchors, 'motif_lineage': rational([dataclasses.asdict(m) for m in motifs])}
if phrase.draft:
entry.update({'draft': rational(dataclasses.asdict(phrase.draft)), 'draft_rests': rational(phrase.draft.rests()), 'edits': rational([dataclasses.asdict(e) for e in phrase.edits]), 'relationships': generator.solo.drafts.relationship_features(phrase.draft, tuple(n for d in phrase.decisions for n in d.notes)), 'home': phrase.state.home.id if phrase.state and phrase.state.home else None, 'current_development': phrase.state.development.id if phrase.state and phrase.state.development else None})
entry['lead_stages'] = {name: {'id': draft.id, 'attacks': len(draft.notes), 'notes': rational([dataclasses.asdict(n) for n in draft.notes])} for name, draft in (('initial', phrase.initial), ('refined', phrase.refined), ('before_enclosure',phrase.before_enclosure)) if draft}
entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes])
if phrase.initial and phrase.refined:
# A source hold may already be sounding at this generated phrase boundary.
# Detach its clipped entrance for the local comparison only; the full
# source comparison below still merges its original tied continuations.
local_source=generator.solo.drafts.local_sounding(phrase.initial.notes)
entry['head_comparison_plain']=generator.solo.similarity.compare(phrase.refined.notes,local_source)
entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),local_source)
if phrase.state and phrase.state.home:
entry['theme_relationship']=generator.solo.features.relationship(phrase.refined.notes,phrase.state.home)
entry['evaluation'] = rational(dataclasses.asdict(phrase.evaluation)) if phrase.evaluation else None
from generator.solo import groups
try:
entry['rhythm_groups']=rational([dataclasses.asdict(g) for g in groups.describe(tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in phrase.draft.notes),origin=phrase.plan.start)]) if phrase.draft else []
except ValueError as error:
entry['rhythm_groups']=[{'family':'source','reason':str(error)}]
entry['score'] = phrase.score
harmony=harmonic.Harmony(solo.choices)
contracts=tuple(c for p in solo.phrases for c in p.expressions)
checked=harmony.evaluate(solo.notes(),until=solo.end,complete=True,expressions=contracts)
phrases=[];endings=[]
for p in solo.phrases:
anchor=p.destination
completed=anchor is None or harmonic.anchor_note(anchor,p.notes) is not None
entry={'id':p.id,'start':str(p.start),'end':str(p.end),'purpose':p.purpose,'direction':p.direction,'ending':p.ending,
'theme':p.theme,'notes':plain(p.notes),'rhythm_groups':plain(p.groups),'sources':p.sources,
'anchors':[{**plain(anchor),'pitch':anchor.pitch.label(),'completed':completed}] if anchor else [],
'ending_completed':completed,'evaluation':plain(p.evaluation),'score':p.score,'expressions':plain(p.expressions)}
phrases.append(entry)
return {'schema':'composer-report-2','selection_trace':rational({k:v for k,v in solo.diagnostics.items() if not k.endswith('_seconds')}), 'instrument_profile': rational(dataclasses.asdict(solo.profile)) if solo.profile else None, 'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'expressions': rational([dataclasses.asdict(e) for e in expressions]), 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures,expressions), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
def phrasing_report(solo):
import collections
from generator.solo import phrasing
notes=solo.notes()
figures=[phrasing.heard(notes,'chorus')] if notes else []
exact=collections.Counter();relative=collections.Counter()
for figure in figures:
# Count occurrences, not just unique vocabulary within each phrase.
for i in range(len(figure.events)-3):
group=figure.events[i:i+4];start,_,pitch=group[0]
exact[tuple((a-start,d,p) for a,d,p in group)]+=1
relative[tuple((a-start,d,p-pitch) for a,d,p in group)]+=1
endings=[];regions=[];appearances=[]
for phrase in solo.phrases:
local=tuple(n for d in phrase.decisions for n in d.notes)
for anchor in phrase.anchors:
if anchor.role!='terminal' or phrase.plan.closure!='closed':continue
note=generator.solo.harmonic.anchor_note(anchor,phrase.state.notes)
endings.append({'phrase':phrase.plan.id,'arrival':str(note.start) if note else None,'pitch':anchor.pitch.label(),
'effect':anchor.effect,'required_beats':str(anchor.duration),'sounding_beats':str(note.end-note.start) if note else None,
'release':str(phrase.plan.release),'completed':note is not None})
if local:
regions.append({'start':str(phrase.plan.start),'end':str(phrase.plan.end),
'low':min(n.pitch.number() for n in local),'high':max(n.pitch.number() for n in local),
'duration_weighted_mean':sum(n.pitch.number()*float(n.end-n.start) for n in local)/sum(float(n.end-n.start) for n in local)})
if phrase.draft:
for transform,feature in zip(phrase.draft.transformations,phrase.draft.features):
appearances.append({'start':str(transform.start),'operation':transform.operation,'home':transform.home,
'preserves':list(feature.preserves),'feature_source':feature.source})
activity={}
for intent in ('active','sustained_arrival','space'):
spans=[s for p in solo.phrases for s in p.plan.passages if s.intent==intent]
duration=sum(float(s.end-s.start) for s in spans)
attacks=sum(any(s.start<=n.start<s.end for s in spans) for n in notes)
occupancy=sum(float(max(0,min(n.end,s.end)-max(n.start,s.start))) for n in notes for s in spans)
activity[intent]={'beats':duration,'attacks':attacks,'attacks_per_four_beats':4*attacks/duration if duration else 0,
'occupancy':occupancy/duration if duration else 0}
return {'completed_sustained_endings':sum(e['completed'] and fractions.Fraction(e['sounding_beats'])>=2 for e in endings),
'endings':endings,'appearances':appearances,'clear_returns':sum(a['operation']=='return' for a in appearances),
'exact_four_note_recurrences':sum(max(0,n-1) for n in exact.values()),
'interval_rhythm_four_note_recurrences':sum(max(0,n-1) for n in relative.values()),
'activity':activity,'register_regions':regions}
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()
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},
'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.',
'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}')
for n in p.notes:lines.append(f' {n.start}–{n.end}: {n.pitch.label()}, {n.role}; string {n.position.string_number}, fret {n.position.fret}')
for c in p.expressions:lines.append(f' {c.kind}/{c.rule} → {c.target.pitch.label()} at {c.target.start}, held to {c.target.end}')
return '\n'.join(lines)+'\n'
def fretboard_report(solo):
import collections
+62
View File
@@ -0,0 +1,62 @@
"""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
class Rhythms:
def __init__(self, library, tempo):
self.groups=[]; limits=vocabulary.data('roles')['difficulty']
for item in vocabulary.data('solo')['groups']:
difficulty=item.get('difficulty')
if difficulty and tempo>limits[difficulty+'_max_bpm']:continue
cursor=F();pattern=[]
for value in item['values']:
duration=F(value.removeprefix('r:'))
if not value.startswith('r:'):pattern.append((cursor,cursor+duration))
cursor+=duration
self.groups.append((item['id'],tuple(pattern),cursor,tuple(item['purpose'])))
for cell in vocabulary.rhythm_cells(tempo):
# Imported authored cells remain whole. They are never sliced to fit.
if groups.valid(cell.pattern):self.groups.append((cell.id,cell.pattern,cell.duration,('develop','answer') if cell.purpose=='answer' else ('arpeggio','scalar','develop','neighbor')))
# Meter-indexed catalog representatives: file counts do not set style frequency.
self.catalog={}
for meter,items in library.snippets.items():
representatives={}
for item in items:
for bar in item.bars:
pattern=tuple((e.onset,e.onset+e.duration) for e in bar.events if e.sounding)
if any(e.continuation for e in bar.events) or not pattern or not groups.valid(pattern):continue
if any(b-a<F(1,2) for a,b in pattern):continue
descriptor=(len(pattern),pattern[0][0]>0,any(a[1]<b[0] for a,b in zip(pattern,pattern[1:])),len(set(b-a for a,b in pattern)))
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
+95 -267
View File
@@ -1,271 +1,99 @@
"""Instantiate complete authored routes; a failed path is never repaired note by note."""
from __future__ import annotations
"""Complete pitch paths for joint phrase proposals, with explicit structural motion."""
import dataclasses
import fractions
from fractions import Fraction as F
from generator import vocabulary
from generator.solo import types, policy, sequences
import generator.fretboard
import generator.solo.placement
import generator.solo.policy
import generator.solo.sequences
import generator.solo.types as types
import generator.vocabulary
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 path_cost(notes):
"""Penalize unexplained extremes, not every third or arpeggio interval."""
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(notes,notes[1:])]
excess=sum(max(0,abs(v)-7) for v in intervals)/max(1,len(intervals))
oscillation=sum(a!=0 and a==-b and a==c and b==d for a,b,c,d in zip(intervals,intervals[1:],intervals[2:],intervals[3:]))
return excess+.1*oscillation
def path_family(notes,harmony):
intervals=tuple(b.pitch.number()-a.pitch.number() for a,b in zip(notes,notes[1:]))
chordal=all(n.pitch.number()%12 in harmony.members(n.start,n.end) for n in notes)
return ('arpeggio' if chordal and any(abs(v)>=3 for v in intervals) else 'mapped',
generator.solo.sequences.family(intervals))
def advance(scale, previous, step):
try:
degree = types.scale_index(scale, previous)
return tuple([types.scale_pitch(scale, degree + step)])
except ValueError:
# At a changed collection count actual intervening scale tones from the
# incoming sound. No old degree number is transferred to the new scale.
if not step:
return ()
nearby = sorted((types.scale_pitch(scale, i) for i in range(-28, 35)), key=lambda p: p.number())
outgoing = [p for p in nearby if (p.number() - previous) * step > 0]
if step < 0:
outgoing.reverse()
return tuple(outgoing[abs(step) - 1:abs(step)])
def candidates(notes, fixed, harmony, library, register, relationship='connect theme', *, purpose='connect', expressions=()):
if expressions:
return planned_candidates(notes,fixed,harmony,register,relationship,expressions)
# Cache concrete routes only with their endpoints, timing, scale context and
# vocabulary. The per-Harmony cache cannot leak across collection changes.
if not hasattr(harmony,'route_cache'):
harmony.route_cache={}
harmony.route_libraries={}
harmony.concrete_routes={}
harmony.route_libraries[id(library)]=library
concrete_key=(notes,frozenset(fixed),id(library),register,harmony.profile,purpose)
if concrete_key in harmony.concrete_routes:
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.concrete_routes[concrete_key])
# Event/source IDs and non-fixed placeholder pitches do not affect route
# feasibility. Cache the pitch work across different thematic histories,
# then restore this caller's provenance before ranking complete routes.
fixed_indices=frozenset(i for i,n in enumerate(notes) if n.event_id in fixed)
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i),role='connection',position=None,
pitch=n.pitch if i==0 or i==len(notes)-1 or i in fixed_indices else types.Pitch('c',fractions.Fraction(),0)) for i,n in enumerate(notes))
key=(canonical,fixed_indices,id(library),register,harmony.profile,purpose)
if key not in harmony.route_cache:
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register,purpose)
result=[]
for route in harmony.route_cache[key]:
actual=tuple(n if i==0 else dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==r.pitch.number() else r.pitch,position=None)
for i,(n,r) in enumerate(zip(notes,route.notes)))
identity=generator.solo.policy.stable_rank('complete-route',(actual,route.sources))[:16]
result.append(dataclasses.replace(route,id=identity,notes=actual,destination=notes[-1],relationship=relationship))
selected=generator.solo.sequences.diverse(result,
lambda r:(path_cost(r.notes),r.id),
lambda r:path_family(r.notes,harmony),
generator.solo.policy.DEFAULT.join_alternatives)
if len(harmony.concrete_routes)>=8192:harmony.concrete_routes.pop(next(iter(harmony.concrete_routes)))
if len(harmony.route_cache)>=8192:harmony.route_cache.pop(next(iter(harmony.route_cache)))
harmony.concrete_routes[concrete_key]=selected
return selected
def planned_candidates(notes,fixed,harmony,register,relationship,contracts):
# Cache complete contract-conditioned pitch work, not a next-note suggestion.
# IDs/provenance are restored below; timing, pitch, role, obligations and
# profile remain in the key. The cache is owned by this harmonic timeline.
ids={n.event_id:str(i) for i,n in enumerate(notes)}
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i)) for i,n in enumerate(notes))
def note(n):return canonical[int(ids[n.event_id])]
obligations=tuple(dataclasses.replace(c,id=str(i),events=tuple(map(note,c.events)),
target=note(c.target),preparation=note(c.preparation) if c.preparation else None) for i,c in enumerate(contracts))
locked=frozenset(ids[i] for i in fixed)
key=(canonical,locked,register,harmony.profile,obligations)
if not hasattr(harmony,'planned_route_cache'):harmony.planned_route_cache={}
if key not in harmony.planned_route_cache:
harmony.planned_route_cache[key]=_planned_candidates(canonical,locked,harmony,register,'',obligations)
if len(harmony.planned_route_cache)>8192:harmony.planned_route_cache.pop(next(iter(harmony.planned_route_cache)))
result=[]
for route in harmony.planned_route_cache[key]:
actual=tuple(dataclasses.replace(original,pitch=realized.pitch,position=realized.position) for original,realized in zip(notes,route.notes))
result.append(dataclasses.replace(route,notes=actual,destination=actual[-1],relationship=relationship))
return tuple(result)
def _planned_candidates(notes,fixed,harmony,register,relationship,contracts):
"""Solve the whole route around explicit, preselected harmonic obligations.
Fixed expressive pitches have narrow permissions; all other attacks use the
map. Complete contracts are checked after realization, never inferred merely
from a label. No incomplete route is handed back to the next cell.
"""
from generator.solo import gestures
proposal=gestures.Proposal(notes,contracts,relationship)
locked={n.event_id for c in contracts for n in (*c.events,c.target)}|set(fixed)|{notes[0].event_id,notes[-1].event_id}
special={n.event_id for c in contracts for n in c.events}
states=[(0.,(notes[0],))]
for i,n in enumerate(notes[1:],1):
span=harmony.at(n.start)
if not span or not span.scale:return ()
allowed=types.permitted(harmony.choices,n.start,n.end)
if n.end-n.start>=2 and n.event_id not in special:allowed &= harmony.members(n.start,n.end)
target=next((a for a in notes[i:] if a.event_id in locked),notes[-1])
remaining=max(1,sum(a.start<target.start for a in notes[i:]))
following=[]
for cost,path in states:
prior=path[-1]
if n.event_id in locked:options=(n.pitch,)
else:
options=tuple(p for step in (-3,-2,-1,1,2,3) for p in advance(span.scale,prior.pitch.number(),step))
if prior.pitch.number()%12 in allowed:options=(*options,prior.pitch)
for pitch in options:
number=pitch.number()
if not harmony.profile.permits(number) or not register[0]<=number<=register[1]:continue
if n.event_id not in special and number%12 not in allowed:continue
motion=number-prior.pitch.number()
if abs(motion)>12:continue
# Continuation aims at the known future destination. Selected thirds
# and fourths remain feasible; repeated oscillation is not progress.
distance=abs(target.pitch.number()-number)
remaining_cost=max(0,distance-5*remaining)/12
repeating=len(path)>1 and path[-2].pitch==prior.pitch==pitch
value=cost+max(0,abs(motion)-7)/12+.2*repeating
following.append((value,(*path,dataclasses.replace(n,pitch=pitch,position=None)),remaining_cost))
chosen=generator.solo.sequences.diverse(following,lambda v:(v[0]+v[2],tuple(n.pitch.number() for n in v[1])),
lambda v:(generator.solo.sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(v[1],v[1][1:]))),v[1][-1].pitch.number()),8)
states=[(cost,path) for cost,path,_ in chosen]
if not states:return ()
result=[]
for _,actual in states:
bound=proposal.bind(actual)
if not harmony.evaluate(actual,until=actual[-1].end,complete=True,expressions=bound).valid:continue
identity=generator.solo.policy.stable_rank('planned-route',tuple((n.start,n.end,n.pitch) for n in actual))[:16]
result.append(types.RouteCandidate(identity,actual,actual[-1],(),relationship,tuple('gesture:'+c.rule for c in bound)))
return tuple(result)
def _candidates(notes, fixed, harmony, library, register, purpose='connect'):
"""Every returned candidate includes its entry, complete rhythm and destination.
Callers validate the whole surrounding sounding context and guitar path.
This function applies exact scale paths, fixed events and range constraints.
"""
if not 2 <= len(notes) <= 16:
return ()
first, destination = notes[0], notes[-1]
span = harmony.at(first.start)
if not span or not span.scale:
return ()
contexts=tuple((harmony.at(n.start).scale if harmony.at(n.start) else None,
types.permitted(harmony.choices,n.start,n.end) & (harmony.members(n.start,n.end) if n.end-n.start>=2 else frozenset(range(12))),
n.pitch.number() if n.event_id in fixed or i==len(notes)-1 else None) for i,n in enumerate(notes))
if not hasattr(harmony,'pitch_routes'):harmony.pitch_routes={}
key=(first.pitch,contexts,id(library),register,harmony.profile)
if key not in harmony.pitch_routes:
harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register,harmony.profile)
if len(harmony.pitch_routes)>8192:harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
route_spans=tuple((max(first.start,c.span.start),min(destination.end,c.span.end),c.scale.label())
for c in harmony.choices if c.scale and c.span.start<destination.end and c.span.end>first.start)
result=[]
paths=list(harmony.pitch_routes[key])
# These are traversal operators, not stored phrases. An arpeggio prefix
# adapts to the chord at each note and a complete scale route then connects
# it to this caller's actual destination. No endpoint is attacked twice.
for prefix,rule in chord_prefixes(notes,fixed,harmony,register,purpose):
i=len(prefix)-1
if i==len(notes)-1:
if prefix[-1].pitch.number()==destination.pitch.number():
paths.append((tuple(n.pitch for n in prefix),('chord-path:'+rule,)))
continue
suffix_key=(prefix[-1].pitch,contexts[i:],id(library),register,harmony.profile)
if suffix_key not in harmony.pitch_routes:
while len(harmony.pitch_routes)>=8192:
harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
harmony.pitch_routes[suffix_key]=pitch_paths(prefix[-1].pitch,contexts[i:],library,register,harmony.profile)
for tail,sources in harmony.pitch_routes[suffix_key][:2]:
paths.append((tuple(n.pitch for n in prefix[:-1])+tail,('chord-path:'+rule,*sources)))
for pitches,sources in paths:
realized=(first,*(dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==p.number() else p,position=None)
for n,p in zip(notes[1:],pitches[1:])))
identity=generator.solo.policy.stable_rank('complete-route',(realized,sources))[:16]
result.append(types.RouteCandidate(identity,realized,destination,route_spans,'connect theme',sources))
return tuple(result)
def chord_prefixes(notes,fixed,harmony,register,purpose):
from generator.solo import phrasing
if len(notes)<2:return ()
values=[]
first=notes[0];destination=notes[-1]
for rule in generator.vocabulary.data('melodic_vocabulary')['chord_paths']:
if purpose not in rule['purpose']:continue
if rule['require_chord_entry'] and first.pitch.number()%12 not in harmony.members(first.start,first.end):continue
displacement=destination.pitch.number()-first.pitch.number()
direction=1 if rule['direction']=='up' else -1 if rule['direction']=='down' else (1 if displacement>=0 else -1)
for count in rule['prefix_attacks']:
if count>len(notes):continue
prefix=[first]
for i,n in enumerate(notes[1:count]):
span=harmony.at(n.start)
if not span or not span.scale:break
eligible=harmony.members(n.start,n.end)&types.permitted(harmony.choices,n.start,n.end)
if rule['members']=='defining':eligible=eligible&phrasing.defining_members(span.chord)
step=rule['member_steps'][i%len(rule['member_steps'])]
old=prefix[-1].pitch.number()
pitches=sorted((types.scale_pitch(span.scale,j) for j in range(-21,29)),key=lambda p:p.number(),reverse=direction*step<0)
options=[p for p in pitches if p.number()%12 in eligible and 0<(p.number()-old)*direction*step
and abs(p.number()-old)<=rule['max_interval'] and register[0]<=p.number()<=register[1] and harmony.profile.permits(p.number())]
if len(options)<abs(step):break
selected=options[abs(step)-1]
if (n.event_id in fixed or i+1==len(notes)-1) and selected.number()!=n.pitch.number():break
prefix.append(dataclasses.replace(n,pitch=selected))
if len(prefix)==count:values.append((tuple(prefix),rule['id']))
return tuple(values)
def pitch_paths(first, contexts, library, register, profile=None):
"""Time-independent pitch work; context includes every overlapped collection.
Sounding durations and chord roles are still validated on each concrete
realization. Equal collection contexts can reuse paths across rhythmic edits.
"""
from generator.solo.profiles import UNRESTRICTED
profile = profile or UNRESTRICTED
if not profile.permits(first.number()): return ()
destination=contexts[-1][2]
try:
displacement=types.scale_index(contexts[0][0],destination)-types.scale_index(contexts[0][0],first.number())
displacements=(displacement,)
except ValueError:
displacements=tuple(range(-7,8))
direction=(destination>first.number())-(destination<first.number())
paths=dict.fromkeys(path for d in displacements for path in library.retrieve(len(contexts),d,direction))
low,high=generator.fretboard.single_register((register[0]-7,register[1]+5))
low, high = max(low, profile.register[0]), min(high, profile.register[1])
result=[]
# Reattacks are an explicit primitive, separate from interval catalog paths
# (whose zero steps are deliberately excluded). Tied holds remain one event.
if all(first.number()%12 in allowed and (fixed is None or fixed==first.number())
for _,allowed,fixed in contexts):
result.append(((first,)*len(contexts),('primitive:repeated_attack',)))
for path in paths:
realized=[first]
for (scale,allowed,fixed),step in zip(contexts[1:],path.steps):
if not scale:break
options=advance(scale,realized[-1].number(),step)
if not options:break
pitch=options[0];number=pitch.number()
if not low<=number<=high or number%12 not in allowed:break
if fixed is not None and number!=fixed:break
if not generator.solo.placement.positions(number,profile):break
realized.append(pitch)
if len(realized)==len(contexts):result.append((tuple(realized),path.sources))
return tuple(result)
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)
+38 -181
View File
@@ -1,191 +1,48 @@
"""Duration-normalized musical evaluation, independent of search/notation chunks."""
from __future__ import annotations
import fractions
import dataclasses
import generator.solo.policy
import generator.solo.types
def passages(plan):
if plan.passages:
return plan.passages
end = plan.end - plan.release
active = (generator.solo.types.ActivitySpan(plan.start, end, 'active', plan.activity, fractions.Fraction(3, 4)),) if end > plan.start else ()
return active + ((generator.solo.types.ActivitySpan(end, plan.end, 'space', fractions.Fraction(), fractions.Fraction()),) if plan.release else ())
def intervals(choices, start, end):
return tuple((max(start, c.span.start), min(end, c.span.end)) for c in choices if c.scale and c.span.start < end and c.span.end > start)
def overlap(start, end, intervals):
return sum((max(0, min(end, b) - max(start, a)) for a, b in intervals), fractions.Fraction())
def corridor(pitch, low, high):
return max(low - pitch, 0, pitch - high)
def motif_fidelity(notes, motif, treatment):
if treatment in ('quote', 'exact_quote'):
return 0.0
if treatment == 'rhythmic_reference':
return 1.0 if len(notes) < min(2, len(motif.notes)) else 0.0
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
expected = motif.contour()
if not actual or not expected:
return 0.0 if len(notes) == len(motif.notes) == 1 else 1.0
count = min(2, len(actual), len(expected)) if treatment == 'opening_reference' else max(1, len(actual) // 2) if treatment == 'answer' else len(actual)
errors = []
for index in range(count):
reference = expected[min(len(expected) - 1, index * len(expected) // max(1, len(actual)))]
motion = actual[index]
direction = ((reference > 0) - (reference < 0)) != ((motion > 0) - (motion < 0))
errors.append(0.75 * direction + 0.25 * min(1, abs(motion - reference) / 12))
evidence = min(2, len(expected))
return max(sum(errors) / len(errors), max(0, evidence - len(actual)) / evidence)
"""One authoritative evaluation of completed phrases, never 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:
"""Prepare fixed harmonic windows once; only soft penalties use float time."""
def __init__(self, plan, choices, until=None):
self.plan = plan
self.until = plan.end if until is None else until
self.duration = max(float(self.until - plan.start), 0.001)
self.windows = []
self.required = []
for passage in passages(plan):
end = min(passage.end, self.until)
if end <= passage.start:
continue
active = intervals(choices, passage.start, end)
length = float(sum((b - a for a, b in active), fractions.Fraction()))
if length:
self.windows.append((float(passage.start), float(end), tuple((float(a), float(b)) for a, b in active), length, float(passage.activity) * length, float(passage.occupancy), 0.5 if self.until < passage.end else 0))
if passage.end <= self.until:
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference') and passage.end <= plan.start + plan.motif.duration
authored_silence = protected and not any(overlap(plan.start + n.start, plan.start + n.end, active) for n in plan.motif.notes)
self.required.append((passage, active, authored_silence))
self.landmarks = tuple((float(p.earliest), float(p.latest), p.low, p.high, p.direction) for p in plan.landmarks if p.latest <= self.until)
def feasible(self, notes):
# Hard silence/space requirements retain exact rational boundaries.
for passage, active, authored_silence in self.required:
if passage.intent == 'space':
if any(n.start < passage.end and n.end > passage.start for n in notes):
return False
elif active and not authored_silence and not any(n.start < b and n.end > a for n in notes for a, b in active):
return False
return True
def features(self, notes, *, previous=None, movement=0):
notes = generator.solo.types.sounding_notes(notes)
sounded = tuple((float(n.start), float(n.end), n.pitch.number()) for n in notes)
activity, occupancy = 0.0, 0.0
for start, end, active, length, expected, occupied_goal, tolerance in self.windows:
attacks = sum(start <= a < end and any(x <= a < y for x, y in active) for a, _, _ in sounded)
occupied = sum(max(0, min(b, y) - max(a, x)) for a, b, _ in sounded for x, y in active)
deviation = max(0, abs(attacks - expected) - max(tolerance, generator.solo.policy.DEFAULT.activity_deadband * length / self.duration))
activity += length * min(1, deviation / max(1, expected))
occupancy += length * max(0, abs(occupied / length - occupied_goal) - generator.solo.policy.DEFAULT.occupancy_deadband)
if self.plan.treatment in ('lead', 'source_variation', 'thematic_recomposition'):
# One broad range over the complete thought; quiet bars are not failed
# local quotas. Missing harmony and explicit release are excluded here.
active = tuple(window for window in self.windows if window[4] > 0)
length = sum(w[3] for w in active)
attacks = sum(any(a <= onset < b for w in active for a, b in w[2]) for onset, _, _ in sounded)
occupied = sum(max(0, min(end, b) - max(start, a)) for start, end, _ in sounded for w in active for a, b in w[2])
low, high = generator.solo.policy.DEFAULT.lead_activity_range
activity = length * corridor(attacks / max(length, .001), low, high) / high
low, high = generator.solo.policy.DEFAULT.lead_occupancy_range
occupancy = length * corridor(occupied / max(length, .001), low, high)
if self.plan.treatment in ('source_variation', 'thematic_recomposition'):
occupancy = 0.0 # Space belongs to the source and its explicit edits.
start, end = float(self.plan.start), float(self.until)
durations = [max(0, min(b, end) - max(a, start)) for a, b, _ in sounded]
register = sum(length * min(1, corridor(pitch, *self.plan.register) / 12) for (_, _, pitch), length in zip(sounded, durations)) / max(sum(durations), 0.001)
motions = [b[2] - a[2] for a, b in zip(sounded, sounded[1:])]
contour = sum(min(1, max(0, abs(m) - 5) / 12) for m in motions) / max(1, len(motions))
repetition = sum(a == b == 0 for a, b in zip(motions, motions[1:])) / max(1, len(motions) - 1)
landmarks = []
for earliest, latest, low, high, direction in self.landmarks:
prior = next((n for n in reversed(sounded) if n[0] < earliest), None)
landmarks.append(min((0.75 * min(1, corridor(n[2], low, high) / 12) + 0.25 * bool(prior and direction and (n[2] - prior[2]) * direction <= 0) for n in sounded if earliest <= n[0] <= latest), default=1))
motif_notes = tuple(n for n in notes if n.role in ('motif', 'quote'))
connection = min(1, abs(sounded[0][2] - previous.pitch.number()) / 12) if sounded and previous else 0
return {'activity': activity / self.duration, 'occupancy': occupancy / self.duration, 'register': register, 'contour': contour, 'repetition': repetition, 'landmarks': sum(landmarks) / max(1, len(landmarks)), 'connection': connection, 'movement': min(1, movement / max(1, len(notes)) / 2000), 'fidelity': motif_fidelity(motif_notes, self.plan.motif, self.plan.treatment) if motif_notes else 0}
def features(plan, notes, choices, *, until=None, previous=None, movement=0):
return Evaluation(plan, choices, until).features(notes, previous=previous, movement=movement)
def value(features):
return sum(weight * features.get(name, 0) for name, weight in generator.solo.policy.DEFAULT.weights)
def feasible(plan, notes, choices, until):
return Evaluation(plan, choices, until).feasible(notes)
@dataclasses.dataclass(frozen=True)
class MusicalEvaluation:
version: str
scope: tuple
valid: bool
pending: tuple
raw: dict
denominators: dict
weights: dict
contributions: dict
total: float
error: str = ''
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)
MUSICAL_WEIGHTS = {'rhythm':3., 'destination':2., 'harmony':2.,
'register':1., 'repetition':1., 'activity':1., 'difficulty':.5}
def musical(plan, draft, notes, incoming, harmony, *, travel=0, weights=None, bar=None):
"""The sole final evaluator of the active thematic composer.
Scores are normalized on complete sounding events, never notation fragments.
Contracts are validity requirements, not additional similarity rewards.
Retrieval estimates are deliberately not added to this result.
"""
from generator.solo import drafts, groups, harmonic, phrasing, types
notes=types.sounding_notes(notes)
bar=getattr(harmony,'bar',fractions.Fraction(4)) if bar is None else bar
from generator import vocabulary
weights=dict(vocabulary.data('grammar').get('evaluation',{}).get('weights',MUSICAL_WEIGHTS) if weights is None else weights)
if set(weights)!=set(MUSICAL_WEIGHTS):raise ValueError('unknown or missing evaluation contribution')
checked=harmony.evaluate(notes, incoming.treatments, until=plan.end, complete=True,
enclosures=draft.enclosures, expressions=draft.expressions)
identity,identity_error=drafts.contract_valid(draft,notes)
anchors,anchor_error=harmonic.anchors_valid(draft.anchors,notes,plan.end,complete=True,closure=plan.closure)
feasible=Evaluation(plan,harmony.choices).feasible(notes)
valid=checked.valid and identity and anchors and feasible
error=checked.error or identity_error or anchor_error or ('' if feasible else 'activity intent not fulfilled')
span=float(plan.end-plan.start); sounded=sum(float(n.end-n.start) for n in notes)
islands=sum(a.end<b.start and b.end<c.start and b.end-b.start<=fractions.Fraction(1,2)
for a,b,c in zip(notes,notes[1:],notes[2:]))
# Rests at the edges can be entrance/release. Long internal gaps need a
# completed gesture, rather than receiving a universal occupancy penalty.
settled={a.hold_until for a in draft.anchors}
broken=sum(max(0,float(b.start-a.end)-1) for a,b in zip(notes,notes[1:])
if a.end not in settled and a.end-a.start<2)
entry=max(0,abs(notes[0].pitch.number()-incoming.last.pitch.number())-7)/12 if notes and incoming.last else 0.
rhythm=min(1.,islands/max(1,len(notes)-2)+broken/max(1,span)+entry/2)
# Explicit source groups retain their imported timing contract. Generated
# groups are checked before construction, not retroactively quantized here.
destinations=[]
for anchor in draft.anchors:
approach=[n for n in notes if n.start<anchor.earliest][-3:]
if approach:
distances=[abs(n.pitch.number()-anchor.pitch.number()) for n in approach]
# A broad approach may turn; large last-second jumps are a preference,
# not a nearest-note mandate for all intermediate events.
destinations.append(min(1,max(0,distances[-1]-5)/12))
goal=harmony.profile.reference
register=sum(float(n.end-n.start)*min(1,max(0,abs(n.pitch.number()-goal)-9)/12)
for n in notes)/max(sounded,.001) if goal is not None else 0.
history=phrasing.repetition(phrasing.heard(notes,draft.relationship),incoming.history,bar) if notes else 0.
activity=Evaluation(plan,harmony.choices).features(notes)['activity']
raw={'rhythm':rhythm,'destination':sum(destinations)/max(1,len(destinations)),
'harmony':checked.exposure,'register':register,'repetition':min(1,history),
'activity':activity,'difficulty':min(1,travel/max(1,len(notes))/2000)}
contributions={name:weights[name]*value for name,value in raw.items()}
return MusicalEvaluation('joint-musical-1',(plan.start,plan.end),valid,checked.pending,raw,
{'span':span,'sounding':sounded,'attacks':len(notes),'destinations':len(destinations)},weights,
contributions,sum(contributions.values()) if valid else float('inf'),error)
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)})
-35
View File
@@ -82,41 +82,6 @@ class Library:
object.__setattr__(self, 'chains', {})
def connecting(self, count: int, displacement: int | None = None) -> tuple[Sequence, ...]:
return tuple(s for s in self.paths.get(count, ()) if displacement is None or s.offsets()[-1] == displacement)
def chained(self, count):
return self.retrieve(count)
def retrieve(self, count, displacement=0, direction=0):
key = (count, displacement, direction)
if key in self.chains:
return self.chains[key]
if not 1 <= count <= 16:
return ()
states, remaining = (Path((), ()),), count - 1
while remaining:
size = min(3, remaining)
options = self.connecting(size + 1)
if not options:
size = next((n for n in range(size - 1, 0, -1) if self.connecting(n + 1)), 0)
if not size:
return ()
options = self.connecting(size + 1)
desired = displacement * (count - 1 - remaining + size) / max(1, count - 1)
def rank(path):
offsets = path.offsets()
motion = offsets[-1]
return abs(motion - desired) + (2 if direction and motion * direction < 0 else 0), max(offsets) - min(offsets), max(map(abs, path.steps), default=0), path.sources
# Retrieve from the entire authored vocabulary, conditioned on each prefix.
following = []
for prior in states:
if count > 4:
local = self.retrieve(size + 1, round(desired - prior.offsets()[-1]), direction)
paths = (Path(prior.steps + s.steps, prior.sources + s.sources) for s in local)
else:
paths = (Path(prior.steps + s.steps, prior.sources + (s.id,)) for s in options)
following.extend(diverse(paths, rank, lambda p: family(p.steps), generator.solo.policy.DEFAULT.retrieval))
states = diverse(following, rank, lambda p: (family(p.steps), (p.offsets()[-1] > 0) - (p.offsets()[-1] < 0)), generator.solo.policy.DEFAULT.retrieval)
remaining -= size
self.chains[key] = states
return states
def read(path: pathlib.Path, identifier: str, count: int) -> Sequence:
document = generator.musicxml.load_musicxml(path)
-92
View File
@@ -1,92 +0,0 @@
"""Duration-aware comparisons of realized music, independent of event labels."""
from __future__ import annotations
import functools
from generator.solo import types, policy
def events(notes):
return tuple((float(n.start),float(n.end),n.pitch.number()) for n in types.sounding_notes(notes))
def occupancy(left, right, modulo=False):
"""Intersection of pitch functions; repeated attacks cannot dilute a hold."""
i=j=0; total=0.
while i<len(left) and j<len(right):
a,b=left[i],right[j]
same = a[2]%12==b[2]%12 if modulo else a[2]==b[2]
if same:
total+=max(0.,min(a[1],b[1])-max(a[0],b[0]))
if a[1]<=b[1]:i+=1
else:j+=1
return total
def interval_match(left, right):
"""Longest exact interval/rhythm fragment, allowing relocation/transposition."""
def tokens(notes):
return tuple((b[0]-a[0],a[1]-a[0],b[2]-a[2]) for a,b in zip(notes,notes[1:]))
a,b=tokens(left),tokens(right); previous={}; best=(0.,0)
for i,x in enumerate(a):
current={}
for j,y in enumerate(b):
if x!=y:continue
count=previous.get(j-1,0)+1
current[j]=count
# The last note's duration also belongs to the matching fragment.
tail=min(left[i+1][1]-left[i+1][0],right[j+1][1]-right[j+1][0])
duration=left[i+1][0]+tail-left[i+1-count][0]
best=max(best,(duration,count+1))
previous=current
return best
@functools.lru_cache(maxsize=8192)
def compare_events(candidate, source):
denominator=sum(b-a for a,b,_ in source)
if not denominator:
return dict(pitch_time=0.,octave_insensitive=0.,attack_agreement=0.,arrival_agreement=0.,
interval_rhythm_coverage=0.,longest_fragment_beats=0.,longest_fragment_attacks=0)
length,count=interval_match(candidate,source)
attacks={(a,b,p%12) for a,b,p in candidate}
arrivals=[n for n in source if n[1]-n[0]>=2]
return dict(pitch_time=occupancy(candidate,source)/denominator,
octave_insensitive=occupancy(candidate,source,True)/denominator,
attack_agreement=sum((a,b,p%12) in attacks for a,b,p in source)/max(1,len(source)),
arrival_agreement=sum((a,b,p%12) in attacks for a,b,p in arrivals)/max(1,len(arrivals)),
interval_rhythm_coverage=min(1.,length/denominator),longest_fragment_beats=length,longest_fragment_attacks=count)
def compare(candidate, source):
return compare_events(events(candidate),events(source))
@functools.lru_cache(maxsize=8192)
def copy_evidence(candidate, source, start, end):
"""Scan source-aligned windows, without an added-event denominator advantage."""
minimum=policy.DEFAULT.theme_copy_beats
threshold=policy.DEFAULT.theme_copy_threshold
fragment,count=interval_match(candidate,source)
copied=fragment>=minimum and count>=policy.DEFAULT.theme_copy_attacks
best=0.; best_offset=0.
if end-start>=minimum and candidate:
windows=sorted({start,*(a for a,_,_ in candidate if start<=a<=end-minimum)})
for left in windows:
right=min(end,left+minimum)
offsets={a[0]-b[0] for a in candidate if left<=a[0]<left+2 for b in source}
for offset in sorted(offsets):
window=tuple((max(left,a+offset),min(right,b+offset),p) for a,b,p in source if a+offset<right and b+offset>left)
count_in=sum(left<=a+offset<right for a,_,_ in source)
duration=sum(b-a for a,b,_ in window)
if count_in<policy.DEFAULT.theme_copy_attacks or duration<minimum/2:
continue
score=occupancy(candidate,window,True)/duration
if score>best:best,best_offset=score,offset
if score>=threshold:copied=True
return {'near_copy':copied,'aligned_occupancy':best,'alignment_offset':best_offset,
'longest_fragment_beats':fragment,'longest_fragment_attacks':count}
def near_copy(notes, source, start, end):
return copy_evidence(events(notes),events(source),float(start),float(end))
-567
View File
@@ -1,567 +0,0 @@
"""Thematic transformations used by the existing complete-draft composer.
Source features initialize a heard theme. Subsequent complete routes develop
that realization, conditioned on the actual incoming musical state.
"""
from __future__ import annotations
import dataclasses
from fractions import Fraction as F
import generator.model
import generator.harmony
import generator.vocabulary
from generator.solo import types, policy, sequences, routes, features as references, similarity, phrasing, profiles, groups as rhythm_groups
def source_notes(analysis):
return tuple(dataclasses.replace(n,start=max(F(),n.start-analysis.body_offset),end=n.end-analysis.body_offset,
event_id=n.sources[0],position=None) for n in analysis.melody if n.end>analysis.body_offset)
def silence(source,incoming,part,harmony,tempo,seed):
from generator.solo import realization
motif=types.Motif('intentional_space',(),source.end-source.start,(),'explicit missing harmony')
plan=types.PhrasePlan(part+'_'+source.id,source.start,source.end,motif,'space','thematic_recomposition',None,harmony.profile.register,F(),F(),
(types.ActivitySpan(source.start,source.end,'space',F(),F()),),passage_id=source.id,passage_start=source.start,passage_end=source.end)
draft=types.MelodicDraft(plan.id,source.start,source.end,(),(),(),(),(),incoming.home,'space',source_phrase=source,source_reference=incoming.source_reference)
phrase=realization.checked_phrase(plan,draft,(),incoming.treatments,incoming,harmony,tempo)
if phrase is None:return ()
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks,
history=incoming.history,returns=incoming.returns,closures=incoming.closures,register_peak=incoming.register_peak)
original=dataclasses.replace(draft,notes=source.notes)
return (dataclasses.replace(phrase,state=state,initial=original,refined=draft,score=0.),)
def short_fragment(source,incoming,part,harmony,tempo,seed,final):
"""An explicitly reduced pickup around missing harmony; not a new theme."""
from generator.solo import leads,realization
reference=incoming.home.notes if incoming.home else source.notes
if not reference:return ()
template=dataclasses.replace(reference[0],start=source.start,end=source.end,position=None,continuation=False,event_id=source.id+':reduction')
result=[]
for pitch in pitches(harmony,template,incoming.last.pitch.number() if incoming.last else template.pitch.number(),members=True):
note=dataclasses.replace(template,pitch=pitch)
motif=types.Motif('reduction',reference,source.end-source.start,(),'explicit short fragment; no claim of full motif fidelity')
anchor=types.Anchor(note.event_id+':arrival',note.start,note.start,pitch,harmony.members(note.start,note.end),harmony.profile.register,note.end-note.start,'terminal' if final else 'intermediate',harmony.at(note.start).chord,hold_until=note.end)
transform=types.Transformation(note.event_id,reference[0].event_id,incoming.home.id if incoming.home else '', 'fragment',note.start,note.end,(note.event_id,),((F(),note.end-note.start),),preserves=())
draft=types.MelodicDraft(part+'_'+note.event_id,source.start,source.end,(note,),(transform,),(note.event_id,),(),(anchor,),incoming.home,'fragment',source_phrase=source,source_reference=incoming.source_reference)
plan=types.PhrasePlan(draft.id,source.start,source.end,motif,'fragment','thematic_recomposition',None,harmony.profile.register,F(5,4),F(),ending='sustain' if final else 'connected',closure='closed' if final else 'open',passage_id=source.id,passage_start=source.start,passage_end=source.end)
phrase=realization.checked_phrase(plan,draft,(note,),incoming.treatments,incoming,harmony,tempo)
if phrase:
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks,
history=incoming.history,returns=incoming.returns,closures=incoming.closures,register_peak=incoming.register_peak)
result.append(dataclasses.replace(phrase,state=state,initial=draft,refined=draft))
return tuple(result)
def seeds(analysis, bar, seed):
melody=tuple(dataclasses.replace(n,start=n.start-analysis.body_offset,end=n.end-analysis.body_offset,
event_id=n.sources[0]) for n in analysis.melody)
groups=[]
for i in range(len(melody)):
for count in ((len(melody),) if len(melody)<3 else (3,4,5,6,7,8)):
group=melody[i:i+count]
if len(group)!=count or group[-1].end-group[0].start>max(2*bar,F(3)):
continue
local=tuple(dataclasses.replace(n,start=n.start-group[0].start,end=n.end-group[0].start) for n in group)
parent=next((s.id for s in analysis.source_phrases if s.start<=max(0,group[0].start)<s.end),'pickup')
for treatment in generator.vocabulary.role().get('preferred_features',('rhythmic_seed','contour_seed','opening_fragment','characteristic_interval')):
identity=policy.stable_rank(seed,(group,treatment))[:12]
groups.append(types.SourceReference(identity,parent,group[0].start,group[-1].end,local,treatment))
def rank(ref):
rhythm=set(rhythm_groups.signature(ref.notes))
intervals={b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])}
preferred=generator.vocabulary.role().get('preferred_features',[])
preference=preferred.index(ref.treatment) if ref.treatment in preferred else 0
turn=any(a*b<0 for a,b in zip(ref.notes and tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])),tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:]))[1:]))
return (-min(4,len(intervals))-.3*turn+.15*preference,-min(3,len(rhythm)),ref.id)
# Preserve different contracts first. Combining treatment with form position
# created many categories of the preferred treatment and could crowd every
# rhythm/contour seed out before a single pitch was constructed.
return sequences.diverse(groups,rank,lambda r:r.treatment,policy.DEFAULT.theme_seed_limit)
def feature(reference, actual, *, treatment=None):
treatment=treatment or reference.treatment
required={'rhythmic_seed':('rhythm',),'contour_seed':('contour',),
'opening_fragment':('rhythm','pitches'),'characteristic_interval':('exact_intervals',),
'repeat_with_harmonic_adaptation':('rhythm','contour'),'answer':('contour',),
'extend':('contour',),'fragment':('rhythm',),'callback':('rhythm','pitches'),
'return':('rhythm','intervals'), 'develop_rhythm':('rhythm',),
'develop_contour':('intervals',), 'continue':(), 'connect':(), 'extension':('contour',)}[treatment]
original=reference.notes
authored=next((r for r in generator.vocabulary.data('grammar')['transformations'] if r['id']==treatment),None)
if authored:required=tuple(authored['preserve'])
origin=original[0].start
octave=12*round((actual[0].pitch.number()-original[0].pitch.number())/12)
return types.IdentifyingFeature(reference.id+':'+treatment,tuple(n.event_id for n in actual),
tuple((n.start-origin,n.end-n.start) for n in original),
tuple(b.pitch.number()-a.pitch.number() for a,b in zip(original,original[1:])),reference.id,required,
tuple(n.pitch.number()+octave for n in original))
def rhythmic_purpose(operation, terminal=False):
if terminal:return 'arrival'
if operation in ('introduce','callback','return','repeat_with_harmonic_adaptation'):return 'entrance'
if operation in ('answer','fragment'):return 'answer'
return 'connect' if operation in ('continue','connect') else 'continue'
def rhythm_cost(pattern,length,purpose):
"""Evaluate a rhythmic job, not resemblance to any target score or density."""
if not pattern:return float('inf')
gaps=[b[0]-a[1] for a,b in zip(pattern,pattern[1:])]
isolated=sum(b-a<=F(1,2) and prev[1]<a and b<nxt[0]
for prev,(a,b),nxt in zip(pattern,pattern[1:],pattern[2:]))
values={b-a for a,b in pattern}
# Equal eighths can be a single directional run. Uniformity alone is not a
# musical defect; aimless long-value packets are addressed by construction.
uniform=.15 if len(pattern)>=4 and len(values)==1 and next(iter(values))>=1 else 0.
if purpose in ('continue','connect'):
# Silence may articulate an idea; disconnected tiny islands do not carry
# unfinished motion. No minimum occupancy or chorus attack quota.
return isolated+.25*float(sum(gaps,F())/length)+uniform
if purpose=='arrival':
return isolated+float(max(F(),F(1)- (pattern[-1][1]-pattern[-1][0])))/2+uniform
return isolated+uniform
def rhythm_windows(library,meter,length,context,purpose='continue'):
from generator.solo import drafts
tempo=getattr(context,'tempo',120)
key=('theme-rhythm',id(library),meter,length,tempo,purpose)
if key not in context.pools:
patterns={}
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
if length>bar:
# The two-bar catalog contains quarter-note masks. Include combinations
# of the richer one-bar material as complete timing alternatives, rather
# than accidentally excluding eighths/dotted values by snippet length.
# Pitch paths and feature contracts span the assembled rhythm's seams.
split=bar
for left,left_id in rhythm_windows(library,meter,split,context,'continue' if purpose=='arrival' else purpose):
for right,right_id in rhythm_windows(library,meter,length-split,context,purpose):
combined=(*left,*((a+split,b+split) for a,b in right))
if len(combined)<=16:patterns.setdefault(combined,left_id+' + '+right_id)
for identifier,pattern,total in drafts.catalog(library,meter):
if total<length:continue
for offset in sorted({F(),*(a for a,_ in pattern)}):
if offset+length>total or any(a<offset<b or a<offset+length<b for a,b in pattern):continue
selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
if selected and len(selected)<=16 and rhythm_groups.valid(selected):patterns.setdefault(selected,identifier)
# Authored cells can start late and continue over computational barlines.
for cell in generator.vocabulary.rhythm_cells(tempo,purpose):
identifier,pattern,total=cell.id,cell.pattern,cell.duration
if total>length:continue
for offset in ((F(),) if cell.entry=='pickup' else (F(),F(1,2),max(F(),length-total))):
if offset+total>length:continue
head=tuple((a+offset,b+offset) for a,b in pattern)
# Keep representative compatible continuations; never let dictionary
# insertion order secretly choose the only tail for an authored cell.
tails=sequences.diverse(tuple(patterns),lambda p:(rhythm_cost(p,length,purpose),policy.stable_rank('rhythm-tail',p)),
lambda p:(len(p),p[-1][1]-p[-1][0]),2) or ((),)
for tail in tails:
if any(a<offset+total<b for a,b in tail):continue
continuation=tuple((a,b) for a,b in tail if a>=offset+total)
if cell.exit=='continue' and offset+total<length and (not continuation or continuation[0][0]!=offset+total):continue
combined=(*head,*continuation)
if 2<=len(combined)<=16 and rhythm_groups.valid(combined):patterns.setdefault(combined,'authored:'+identifier)
context.pools[key]=sequences.diverse(tuple(patterns.items()),
lambda x:(rhythm_cost(x[0],length,purpose)+(0 if x[1].startswith('authored:') else .05),policy.stable_rank('rhythm-window',x[0])),
lambda x:(x[1].startswith('authored:'),len(x[0]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8)
return context.pools[key]
def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operation='introduce', terminal=False, ending=None):
length=finish-start; values={}
purpose=rhythmic_purpose(operation,terminal)
if home and operation in ('repeat_with_harmonic_adaptation','return'):
pattern=tuple((n.start,n.end) for n in home.notes if n.end<=length)
if pattern:values[pattern]='heard-theme'
prefix=tuple((n.start,n.end) for n in ref.notes)
fixed_rhythm=ref.treatment in ('rhythmic_seed','opening_fragment','callback') or operation in ('fragment','repeat_with_harmonic_adaptation','return','develop_rhythm')
if fixed_rhythm:
duration=prefix[-1][1]
if duration==length:values[prefix]=ref.id
if duration<length:
for tail,label in rhythm_windows(rhythms,meter,length-duration,context,purpose):
values[(*prefix,*((a+duration,b+duration) for a,b in tail))]=ref.id+' + '+label
else:
values.update(rhythm_windows(rhythms,meter,length,context,purpose))
if home and operation=='answer':
for p,label in rhythm_windows(rhythms,meter,length,context,purpose):values.setdefault(p,label)
if not fixed_rhythm:
# Develop the heard timing, preserving total duration. Eighth/quarter/dotted
# alternatives are actual contracts, not failed copies of a rhythmic seed.
for original,label in tuple(values.items()):
movement=generator.vocabulary.data('melodic_vocabulary')['motion_subdivision']
for i,(a,b) in enumerate(original[:-1]):
pulse=F(movement['pulse'])
if b-a<F(movement['minimum_value']) or (a/pulse).denominator!=1 or ((b-a)/pulse).denominator!=1:continue
moving=tuple((a+j*pulse,a+(j+1)*pulse) for j in range(int((b-a)/pulse)))
revised=(*original[:i],*moving,*original[i+1:])
if len(revised)<=movement['max_attacks']:values[revised]=label+'; moving continuation'
for i,(a,b) in enumerate(original):
if b-a==1:
values[(*original[:i],(a,a+F(1,2)),(a+F(1,2),b),*original[i+1:])]=label+'; diminution'
for i,((a,b),(c,d)) in enumerate(zip(original,original[1:])):
if b!=c or any((v*2).denominator!=1 for v in (a,b,c,d)):continue
for split in (a+F(1,2),a+F(3,2),d-F(1,2)):
if a<split<d:
values[(*original[:i],(a,split),(split,d),*original[i+2:])]=label+'; rhythmic alteration'
if ending:
arrivals={}
arrival=ending.earliest-start
for original,label in values.items():
try: revised=rhythm_groups.broaden(original,arrival,ending.hold_until-start)
except ValueError: revised=()
if revised and len(revised)>=len(ref.notes):
arrivals[revised]=label+'; planned sounding destination'
values=arrivals
values={p:label for p,label in values.items() if rhythm_groups.valid(p) or fixed_rhythm and p==prefix}
return sequences.diverse(tuple(values.items()),lambda x:(rhythm_cost(x[0],length,purpose),policy.stable_rank(ref.id,x[0])),
lambda x:(len(x[0]),tuple(b-a for a,b in x[0][:3]),x[0][0][0]),policy.DEFAULT.theme_rhythm_alternatives)
def pitches(harmony,note,base,*,members=False,limit=6,incoming=None):
from generator.solo.leads import available
domain=available(harmony,note.start,note.end,members=members or note.end-note.start>=2)
if members:
return phrasing.destination_options(domain,lambda p:(phrasing.destination_cost(p,note.start,note.end,harmony,incoming,base),p.number()),
lambda p:p,lambda p:note.start,harmony,limit)
return sequences.diverse(domain,lambda p:(abs(p.number()-base),p.number()),
lambda p:((p.number()-base>0)-(p.number()-base<0),p.number()//12),limit)
def relative_steps(notes,harmony):
result=[]
for a,b in zip(notes,notes[1:]):
scale=harmony.at(b.start).scale
try:step=types.scale_index(scale,b.pitch.number())-types.scale_index(scale,a.pitch.number())
except ValueError:
lo,hi=sorted((a.pitch.number(),b.pitch.number()))
intervening=range(lo+1,hi+1) if b.pitch.number()>a.pitch.number() else range(lo,hi)
count=sum(p%12 in scale.pitches for p in intervening)
step=count if b.pitch.number()>a.pitch.number() else -count
result.append(step)
return tuple(result)
@dataclasses.dataclass(frozen=True)
class Unit:
notes: tuple[types.Note,...]
feature: types.IdentifyingFeature
reference: types.SourceReference
operation: str
anchor: types.Anchor
score: float
source: str
routes: tuple[types.RouteCandidate,...] = ()
start: F = F()
end: F = F()
expressions: tuple[types.ExpressionContract,...] = ()
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None,allow_expressions=True):
"""Joint whole-gesture pitch/rhythm actions, conditioned on the actual entry.
A complete route is proposed before thematic evidence is checked. No motif
prefix is spliced to an independently cheapest suffix. Ordinary search ends
carry no chord-tone obligation; long-note and destination contracts still do.
"""
from generator.solo import leads
key=('joint-gesture-1',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,
home,operation,ending,goal,batch,allow_expressions,getattr(context,'expressions_enabled',True),id(harmony),id(library),id(rhythms))
if key in context.results:
context.diagnostics['cache_hits']+=1;return context.results[key]
trace=context.diagnostics.setdefault('operations',{}).setdefault(operation,
dict(eligible=0,proposed=0,valid=0,retained=0,jobs=0,pruned_jobs=0,rejected_by_rule=0))
trace['eligible']+=1
require_feature=operation not in ('continue','connect')
count=len(ref.notes) if require_feature else 0
jobs=[]
for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal,ending=ending):
if len(pattern)<max(2,count) or len(pattern)>16:continue
provenance=tuple(dict.fromkeys(s for n in ref.notes for s in n.sources))
templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,provenance,
'theme' if require_feature else 'connection',event_id=f'{ref.id}:{operation}:{start}:{i}') for i,(a,b) in enumerate(pattern))
base=incoming.last.pitch.number() if incoming.last else harmony.profile.reference or ref.notes[0].pitch.number()
firsts=pitches(harmony,templates[0],base,limit=6)
if incoming.last is None:
offsets=generator.vocabulary.role().get('register_alternatives',[-7,0,7])
firsts=tuple(dict.fromkeys(p for offset in offsets for p in pitches(harmony,templates[0],base+offset,limit=2)))
targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,
members=False,limit=policy.DEFAULT.theme_destinations,incoming=incoming)
for first in firsts:
for target in targets:
motion=ref.notes[-1].pitch.number()-ref.notes[0].pitch.number()
if operation=='answer':motion=-motion
# Rank purposeful direction separately from entry continuity. No reward
# for minimizing every interval or for repeatedly visiting the midpoint.
rank=max(0,abs(first.number()-base)-7)/12
rank+=.1*min(1,abs((target.number()-first.number())-motion)/12)
jobs.append((rank,templates,label,first,target))
selected=sequences.diverse(jobs,lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4]))),
lambda j:(tuple(n.end-n.start for n in j[1]),j[3].number()//12,
(j[4].number()>j[3].number())-(j[4].number()<j[3].number())),policy.DEFAULT.theme_route_jobs*(batch+1))
trace['jobs']+=len(jobs);trace['pruned_jobs']+=len(jobs)-len(selected)
result=[]
from generator.solo import gestures
scheduled=[]
for rank,templates,label,first,target in selected:
timed=(dataclasses.replace(templates[0],pitch=first),*templates[1:-1],dataclasses.replace(templates[-1],pitch=target))
scheduled.append((rank,gestures.Proposal(timed,(),'direct'),label))
if allow_expressions and getattr(context,'expressions_enabled',True):
for rank,plain,label in tuple(scheduled[:4]):
scheduled.extend((rank,p,label+'; '+p.purpose) for p in gestures.select(plain.notes,harmony,start,finish,ending,limit=4))
funnel=context.diagnostics.setdefault('joint_gestures',{})
def record(purpose,stage,count=1):
values=funnel.setdefault(purpose,dict(proposed=0,retained_jobs=0,valid_routes=0,retained_units=0))
values[stage]+=count
for _,p,_ in scheduled:record(p.purpose,'proposed')
scheduled=sequences.diverse(scheduled,lambda v:(v[0],policy.stable_rank(ref.id,(v[1].purpose,tuple((n.start,n.end,n.pitch) for n in v[1].notes)))),
lambda v:tuple(c.kind for c in v[1].contracts) or ('direct',),policy.DEFAULT.theme_route_jobs*(batch+1))
for _,proposal,label in scheduled:
record(proposal.purpose,'retained_jobs')
timed=proposal.notes;first=timed[0].pitch;target=timed[-1].pitch;templates=timed
if not max(2,count)<=len(timed)<=16:continue
fixed={timed[-1].event_id}|{n.event_id for n in timed if n.role=='arrival'}
proposed=list(routes.candidates(timed,fixed,harmony,library,harmony.profile.register,
operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal),expressions=proposal.contracts))
# A substantial literal return is a musical action, not merely the hope
# that a general interval search will accidentally reproduce the theme.
if proposal.purpose=='direct' and require_feature and len(templates)==len(ref.notes):
delta=first.number()-ref.notes[0].pitch.number()
actual=[]
for template,n in zip(templates,ref.notes):
options=leads.available(harmony,template.start,template.end,members=template.end-template.start>=2)
p=next((p for p in options if p.number()==n.pitch.number()+delta),None)
if p is None:break
actual.append(dataclasses.replace(template,pitch=p))
if len(actual)==len(templates) and actual[-1].pitch==target:
proposed.append(types.RouteCandidate('theme:'+ref.id,tuple(actual),actual[-1],(),operation,(ref.id,)))
for route in proposed:
trace['proposed']+=1
actual=route.notes
contract=feature(ref,actual[:count],treatment=operation if operation!='introduce' else None) if count else None
if contract and references.feature_error(contract,actual):
trace['rejected_by_rule']+=1;continue
contracts=proposal.bind(actual)
checked=harmony.evaluate(actual,until=finish,complete=True,expressions=contracts)
if not checked.valid:trace['rejected_by_rule']+=1;continue
trace['valid']+=1
record(proposal.purpose,'valid_routes')
entry=max(0,abs(first.number()-incoming.last.pitch.number())-7)/12 if incoming.last else 0.
score=2*checked.exposure+entry+.1*routes.path_cost(actual)
score+=.25*rhythm_cost(tuple((n.start-start,n.end-start) for n in actual),finish-start,rhythmic_purpose(operation,terminal))
result.append(Unit(actual,contract,ref,operation,ending,score,label,(route,),start,finish,contracts))
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
chosen=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
lambda u:(routes.path_family(u.notes,harmony),u.notes[0].pitch.number()//12,
tuple(n.end-n.start for n in u.notes),tuple(c.kind for c in u.expressions)),policy.DEFAULT.theme_unit_alternatives)
trace['retained']+=len(chosen)
for u in chosen:
record(u.source.split('; ')[-1] if '; ' in u.source else 'direct','retained_units')
context.diagnostics['generated_candidates']+=len(chosen)
context.results[key]=chosen
return chosen
def home_reference(home,operation):
# A return refers to the opening the listener heard, not whichever later
# three-note turn happens to rank best. Development may use its smaller core.
notes = home.signature or home.notes[:min(4, len(home.notes))]
if operation not in ('return', 'repeat_with_harmonic_adaptation'):
notes = notes[:3]
origin=notes[0].start
notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in notes)
treatment='return' if operation=='return' else 'rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','fragment','develop_rhythm') else 'contour_seed'
return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,treatment)
def make_home(unit,harmony,start=None,end=None):
from generator.solo.realization import make_identity
start=unit.notes[0].start if start is None else start
identity=policy.stable_rank('solo-theme',(unit.reference.id,unit.notes))[:16]
home=make_identity('solo_theme_'+identity,unit.reference.id,unit.notes,start,unit.notes[-1].end if end is None else end,'introduce',harmony)
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony),signature=home.notes[:len(unit.reference.notes)])
def operation_choices(home,incoming,cursor,phrase_start,closing):
available=generator.vocabulary.data('grammar')['operations']
selected=[]
for rule in available:
op=rule['id']
if rule.get('entry_only') and cursor!=phrase_start:continue
if rule.get('after_statement') and not home:continue
if rule.get('needs_recent') and not incoming.notes:continue
selected.append(op)
return tuple(selected)
def unit_end(source,cursor,home,ending,bar):
"""Bound search by time without cutting the heard signature at a barline."""
length=2*bar
if home and home.signature:
length=home.signature[-1].end
while 0<length<bar:
length+=home.signature[-1].end
length=min(2*bar,max(bar,length))
finish=min(source.end,cursor+length)
if source.end-finish<bar+2 or ending and ending.earliest<finish:
finish=source.end
return finish
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False,solo_end=None):
from generator.solo import leads,realization
if source.end-source.start<2:
return short_fragment(source,incoming,part,harmony,tempo,seed,final)
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
solo_end=solo_end or max(c.span.end for c in harmony.choices)
head=source_notes(analysis)
seed_key=('source-seeds',analysis,bar,seed)
if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed)
roots=context.pools[seed_key]
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=6*(batch+1),final=final)
if not final:ending_options=(None,*ending_options)
states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options]
while states and any(not units or units[-1].end<source.end for _,units,_,_,_ in states):
next_states=[]
for cost,units,home,state,ending in states:
cursor=units[-1].end if units else source.start
if cursor>=source.end:
next_states.append((cost,units,home,state,ending))
continue
finish=unit_end(source,cursor,home,ending,bar)
introducing=home is None
if introducing:
requests=[(r,'introduce') for r in roots]
else:
operations=operation_choices(home,state,cursor,source.start,finish==source.end)
requests=[(home_reference(home,op),op) for op in operations]
# A uniform duration alone is not identifying musical evidence.
requests=[(r,op) for r,op in requests if op!='develop_rhythm' or len({n.end-n.start for n in r.notes})>1 or any(a.end!=b.start for a,b in zip(r.notes,r.notes[1:]))]
for ref,operation in requests:
if introducing and ending and source.end-source.start<2*bar and ending.earliest-cursor<ref.notes[-1].end:
# A missing-harmony boundary can leave less than one full statement.
# Declare a smaller contour seed; never pretend it proves the full motif.
count=min(3,int(2*(ending.earliest-cursor)),len(ref.notes))
if count<2:
if len(ref.notes)!=1:continue
n=dataclasses.replace(ref.notes[0],end=min(ref.notes[0].end,F(1),ending.earliest-cursor))
ref=dataclasses.replace(ref,id=ref.id+':constrained_pitch',notes=(n,),treatment='opening_fragment')
else:
ref=dataclasses.replace(ref,id=ref.id+':constrained_contour',notes=ref.notes[:count],treatment='contour_seed')
if operation=='continue' and state.notes:
recent=state.notes[-min(3,len(state.notes)):]
origin=recent[0].start
ref=dataclasses.replace(ref,notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in recent),treatment='contour_seed')
if ref.notes[-1].end>finish-cursor:
reduced=tuple(n for n in ref.notes if n.end<=finish-cursor)
if len(reduced)<2:continue
ref=dataclasses.replace(ref,notes=reduced,treatment='contour_seed')
operation='continue'
terminal=bool(ending and finish==source.end)
base=state.last.pitch.number() if state.last else ref.notes[0].pitch.number()
destination=ending.pitch.number() if ending else phrasing.register_goal(state,part,base,harmony.profile)
goal=destination if finish==source.end else round(base+(destination-base)*float((finish-cursor)/(source.end-cursor)))
options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation,
terminal=terminal,batch=batch,ending=ending if terminal else None,goal=goal,allow_expressions=stage=='elaborated')
for unit in options:
if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']:
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
actual_home=home or make_home(unit,harmony,cursor,finish)
figure=phrasing.heard(unit.notes,operation,unit.anchor,bar)
repetition=phrasing.repetition(figure,state.history,bar)
appearance=phrasing.appearance_cost(operation,state,cursor,solo_end) if home else 0.
history=phrasing.remember(state.history,figure,bar)
following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref,
history=history,returns=(*state.returns,*((cursor,) if operation=='return' else ())),
register_peak=max(state.register_peak,*(n.pitch.number() for n in unit.notes)))
next_states.append((cost+float(finish-cursor)*(unit.score+.8*repetition+appearance),(*units,unit),actual_home,following,ending))
def rank(value):
cost,units,home,state,ending=value
elapsed=units[-1].end-source.start
return cost/max(.001,float(elapsed)),policy.stable_rank(seed,tuple(u.notes for u in units))
states=sequences.diverse(next_states,rank,
lambda v:(v[2].id,(v[4].pitch.number(),v[4].duration) if v[4] else 'open',len(v[1][-1].notes),v[1][-1].operation),policy.DEFAULT.theme_continuations)
if not states:return ()
result=[]
for cost,units,home,state,ending in states:
plain=tuple(n for u in units for n in u.notes)
if similarity.near_copy(plain,head,source.start,source.end)['near_copy']:
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
features=tuple(u.feature for u in units if u.feature)
# Cell tails are not compulsory melodic arrivals. Only the selected close
# has a sounding contract; intermediate turns remain editable.
anchors=(ending,) if ending else ()
tails=tuple(types.SustainedTail(n.start,n.end,n.pitch.number()) for n in plain if n.role=='arrival')
if ending:tails=(*tails,types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()))
options=[(plain,(),())]
for notes,edits,selected_routes in options:
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,leads.signature(notes))[:12]
transforms=tuple(types.Transformation(identifier+f':{i}',u.reference.id,home.id,u.operation,u.notes[0].start,u.notes[-1].end,
tuple(n.event_id for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),tuple((n.start-u.notes[0].start,n.end-u.notes[0].start) for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),preserves=(),source=u.reference.id+'; rhythm '+u.source) for i,u in enumerate(units))
source_ref=state.source_reference
draft=types.MelodicDraft(identifier,source.start,source.end,notes,transforms,tuple(n.event_id for n in notes if n.role=='arrival'),(),anchors,home,'introduce' if not incoming.home else 'develop',
features=features,reference=source_ref.id,reference_motif=home,source_phrase=source,source_reference=source_ref,
source_links=tuple((n.event_id,n.sources) for n in notes),sustained_tails=tails,
expressions=tuple(c for u in units for c in u.expressions))
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
landmarks=(types.Landmark(ending.earliest,ending.latest,ending.pitch.number(),ending.pitch.number(),0,'sounding destination'),) if ending else ()
closed=bool(ending and ending.role=='terminal')
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,harmony.profile.register,F(5,4),
source.end-ending.hold_until if ending else F(),phrasing.activity(source,ending,tuple(n for n in notes if n.role=='arrival')),landmarks,
'sustain' if ending else 'connected','closed' if closed else 'open',seed,
source.id,source.start,source.end,(anchors,))
before_rhythm=draft
from generator.solo import gesture_rhythm
if stage=='elaborated':
draft,rhythm_edits=gesture_rhythm.revise(draft,harmony,incoming.treatments)
notes=draft.notes;edits=(*edits,*rhythm_edits);transforms=draft.transformations
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
if phrase is None:continue
history=incoming.history
for transform in transforms:
group=tuple(n for n in notes if n.event_id in transform.events)
if group:history=phrasing.remember(history,phrasing.heard(group,transform.operation,ending if ending and group[-1].end==ending.hold_until else None,bar),bar)
current=dataclasses.replace(phrase.state,next_destination=phrasing.next_destination(ending,harmony) if ending else incoming.next_destination,home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units),
history=history,returns=state.returns,closures=incoming.closures+int(closed),register_peak=max(state.register_peak,*(n.pitch.number() for n in notes)))
if not incoming.home:
introduced=tuple(n for n in notes if n.start<units[0].notes[-1].end)
heard=dataclasses.replace(home,notes=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced),
degree_steps=relative_steps(introduced,harmony),signature=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced[:len(units[0].reference.notes)]))
current=dataclasses.replace(current,home=heard,themes=(heard,))
source_control=leads.shift(source.notes,next((o for o in (0,1,-1,2,-2) if all(41<=n.pitch.number()+12*o<=76 for n in source.notes)),0))
original=dataclasses.replace(draft,id=identifier+':source',notes=source_control,features=(),anchors=(),structural=(),sustained_tails=())
refined=dataclasses.replace(draft,id=identifier+':plain',notes=plain)
result.append(dataclasses.replace(phrase,state=current,initial=original,refined=refined,before_rhythm=before_rhythm,routes=(*tuple(r for u in units for r in u.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in notes) for n in r.notes)),*selected_routes)))
from generator.solo import expressions,gesture_rhythm
if stage=='elaborated':
revisions=[]
for base in result:
for changed,edit,route in gesture_rhythm.join_variants(base.draft,harmony,library):
revised=realization.checked_phrase(base.plan,changed,changed.notes,incoming.treatments,incoming,harmony,tempo,(*base.edits,edit))
if revised is None:continue
state=dataclasses.replace(base.state,notes=revised.state.notes,last=revised.state.last,
motifs=revised.state.motifs,travel=revised.state.travel)
history=incoming.history
for t in changed.transformations:
heard=tuple(n for n in changed.notes if n.event_id in t.events)
if heard:history=phrasing.remember(history,phrasing.heard(heard,t.operation,bar=bar),bar)
state=dataclasses.replace(state,history=history)
retained=tuple(r for r in base.routes if not any(n.start<edit.end and n.end>edit.start for n in r.notes))
revisions.append(dataclasses.replace(revised,state=state,initial=base.initial,refined=base.refined,
before_rhythm=base.draft,routes=(*retained,route)))
context.diagnostics['join_revision_candidates']=context.diagnostics.get('join_revision_candidates',0)+len(revisions)
result.extend(revisions)
if stage=='elaborated' and getattr(context,'expressions_enabled',True):
result=expressions.joint_candidates(result,incoming,harmony,tempo,context,bar=bar)
selected=sequences.diverse(result,lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
lambda p:(p.state.home.id,p.plan.closure,p.state.last.pitch.number(),tuple(e.kind for e in p.draft.expressions)),policy.DEFAULT.phrase_alternatives)
context.diagnostics.setdefault('evaluations',[]).extend(
{'id':p.draft.id,'retained':p in selected,'evaluation':dataclasses.asdict(p.evaluation)} for p in sorted(result,key=lambda p:p.score)[:12] if p.evaluation)
return selected
+40 -274
View File
@@ -1,15 +1,13 @@
"""Shared sounding events and completed musical phrases; no draft/cell hierarchy."""
from __future__ import annotations
import dataclasses
import fractions
import functools
import generator.fretboard
import dataclasses, fractions, functools
import generator.model
import generator.fretboard
import generator.scales.library
import generator.scales.selection
import generator.solo.profiles
@dataclasses.dataclass(frozen=True, order=True)
class Pitch:
letter: str
@@ -18,6 +16,12 @@ class Pitch:
def __post_init__(self):
if self.letter not in generator.model.NATURAL or self.alter.denominator != 1:
raise ValueError('solo pitches require spelled semitones from the permitted scales')
@functools.lru_cache(maxsize=512)
def __repr__(self):
# Exactly the former dataclass representation: cache formatting without
# changing deterministic choice keys or introducing another seed format.
return f'Pitch(letter={self.letter!r}, alter={self.alter!r}, octave={self.octave!r})'
@functools.lru_cache(maxsize=512)
def number(self) -> int:
return generator.model.NATURAL[self.letter] + int(self.alter) + 12 * (self.octave + 1)
def written(self) -> dict:
@@ -48,42 +52,6 @@ def sounding_notes(notes):
result.append(note)
return tuple(result)
@dataclasses.dataclass(frozen=True)
class Motif:
id: str
notes: tuple[Note, ...]
duration: fractions.Fraction
context: tuple[str, ...]
origin: str = 'melody'
def contour(self) -> tuple[int, ...]:
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
@dataclasses.dataclass(frozen=True)
class SourcePhrase:
id: str
start: fractions.Fraction
end: fractions.Fraction
notes: tuple[Note, ...]
rests: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
family: str
pickup: tuple[Note, ...] = ()
@dataclasses.dataclass(frozen=True)
class TargetEvent:
earliest: fractions.Fraction
latest: fractions.Fraction
pitches: frozenset[int]
chord: str
reason: str
@dataclasses.dataclass(frozen=True)
class ActivitySpan:
start: fractions.Fraction
end: fractions.Fraction
intent: str
activity: fractions.Fraction
occupancy: fractions.Fraction
@dataclasses.dataclass(frozen=True)
class HarmonicTreatment:
id: str
@@ -108,13 +76,6 @@ class ExpressionContract:
boundary: fractions.Fraction | None = None
preparation: Note | None = None
@dataclasses.dataclass(frozen=True)
class LeadIdentity:
id: str
durations: tuple[fractions.Fraction, ...]
degrees: tuple[int, ...]
source: str
@dataclasses.dataclass(frozen=True)
class Anchor:
id: str
@@ -130,120 +91,12 @@ class Anchor:
hold_until: fractions.Fraction | None = None
effect: str = 'local_settle'
@dataclasses.dataclass(frozen=True)
class Gesture:
id: str
start: fractions.Fraction
end: fractions.Fraction
relationship: str
treatment: str
@dataclasses.dataclass(frozen=True)
class RealizedMotif:
id: str
parent: str
notes: tuple[Note, ...]
duration: fractions.Fraction
relationship: str
treatment: str
chord_degrees: tuple[tuple[int, ...], ...] = ()
source_start: fractions.Fraction = fractions.Fraction()
degree_steps: tuple[int, ...] = ()
signature: tuple[Note, ...] = ()
def contour(self):
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
@dataclasses.dataclass(frozen=True)
class ContinuationState:
passage_id: str
notes: tuple[Note, ...] = ()
motifs: tuple[RealizedMotif, ...] = ()
treatments: tuple[HarmonicTreatment, ...] = ()
anchors: tuple[str, ...] = ()
direction: int = 0
last: Note | None = None
backbone: int = 0
fidelity: tuple[float, ...] = ()
travel: int = 0
home: RealizedMotif | None = None
development: RealizedMotif | None = None
themes: tuple[RealizedMotif, ...] = ()
selected_anchors: tuple[Anchor, ...] = ()
ending: str = ''
lead_identity: LeadIdentity | None = None
source_reference: SourceReference | None = None
callbacks: int = 0
history: tuple[HeardFigure, ...] = ()
returns: tuple[fractions.Fraction, ...] = ()
closures: int = 0
register_peak: int = 0
next_destination: tuple[int, ...] = ()
@dataclasses.dataclass(frozen=True)
class HeardFigure:
start: fractions.Fraction
end: fractions.Fraction
events: tuple[tuple[fractions.Fraction, fractions.Fraction, int], ...]
operation: str
ending: tuple = ()
@dataclasses.dataclass(frozen=True)
class SustainedTail:
start: fractions.Fraction
end: fractions.Fraction
pitch: int
@dataclasses.dataclass(frozen=True)
class Transformation:
id: str
parent: str
home: str
operation: str
start: fractions.Fraction
end: fractions.Fraction
events: tuple[str, ...]
rhythm: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
directions: tuple[int, ...] = ()
preserves: tuple[str, ...] = ('rhythm', 'opening_contour')
source: str = ''
@dataclasses.dataclass(frozen=True)
class DraftEdit:
kind: str
start: fractions.Fraction
end: fractions.Fraction
removed: tuple[str, ...]
added: tuple[str, ...]
source: str = ''
@dataclasses.dataclass(frozen=True)
class IdentifyingFeature:
id: str
events: tuple[str, ...]
rhythm: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
intervals: tuple[int, ...]
source: str
preserves: tuple[str, ...] = ('rhythm', 'intervals')
pitches: tuple[int, ...] = ()
@dataclasses.dataclass(frozen=True)
class SourceReference:
id: str
source_id: str
start: fractions.Fraction
end: fractions.Fraction
notes: tuple[Note, ...]
treatment: str
@dataclasses.dataclass(frozen=True)
class RouteCandidate:
id: str
notes: tuple[Note, ...]
destination: Note
spans: tuple[tuple[fractions.Fraction, fractions.Fraction, str], ...]
relationship: str
sources: tuple[str, ...]
@dataclasses.dataclass(frozen=True)
class EnclosureContract:
"""A complete, timed upper/lower approach to a previously chosen arrival.
@@ -260,123 +113,6 @@ class EnclosureContract:
protected_tail: SustainedTail
approach_start: fractions.Fraction | None = None
@dataclasses.dataclass(frozen=True)
class MelodicDraft:
id: str
start: fractions.Fraction
end: fractions.Fraction
notes: tuple[Note, ...]
transformations: tuple[Transformation, ...]
structural: tuple[str, ...]
protected: tuple[str, ...]
anchors: tuple[Anchor, ...]
home: RealizedMotif | None
relationship: str
joins: tuple[fractions.Fraction, ...] = ()
features: tuple[IdentifyingFeature, ...] = ()
reference: str = ''
reference_motif: Motif | RealizedMotif | None = None
lead_identity: LeadIdentity | None = None
correspondence: tuple[tuple[str, str], ...] = ()
source_phrase: SourcePhrase | None = None
octave_transfer: int = 0
source_links: tuple[tuple[str, tuple[str, ...]], ...] = ()
source_reference: SourceReference | None = None
sustained_tails: tuple[SustainedTail, ...] = ()
enclosures: tuple[EnclosureContract, ...] = ()
expressions: tuple[ExpressionContract, ...] = ()
def rests(self):
cursor, result = self.start, []
for note in self.notes:
if note.start > cursor:
result.append((cursor, note.start))
cursor = max(cursor, note.end)
if cursor < self.end:
result.append((cursor, self.end))
return tuple(result)
@dataclasses.dataclass(frozen=True)
class MelodicPassagePlan:
id: str
start: fractions.Fraction
end: fractions.Fraction
phrase_ids: tuple[str, ...]
seed: str
@dataclasses.dataclass(frozen=True)
class Landmark:
earliest: fractions.Fraction
latest: fractions.Fraction
low: int
high: int
direction: int = 0
name: str = 'arrival'
@dataclasses.dataclass(frozen=True)
class PhrasePlan:
id: str
start: fractions.Fraction
end: fractions.Fraction
motif: Motif
relationship: str
treatment: str
target: TargetEvent | None
register: tuple[int, int]
activity: fractions.Fraction
release: fractions.Fraction = fractions.Fraction(1, 2)
passages: tuple[ActivitySpan, ...] = ()
landmarks: tuple[Landmark, ...] = ()
ending: str = 'breath'
closure: str = 'closed'
seed: str = ''
passage_id: str = ''
passage_start: fractions.Fraction | None = None
passage_end: fractions.Fraction | None = None
anchor_options: tuple[tuple[Anchor, ...], ...] = ()
gestures: tuple[Gesture, ...] = ()
treatments: tuple[HarmonicTreatment, ...] = ()
@dataclasses.dataclass(frozen=True)
class Decision:
start: fractions.Fraction
end: fractions.Fraction
source: str
treatment: str
notes: tuple[Note, ...]
fidelity: float = 0
gesture_id: str = ''
parent_motif: str = ''
@dataclasses.dataclass(frozen=True)
class Phrase:
plan: PhrasePlan
decisions: tuple[Decision, ...]
score: float = 0
state: ContinuationState | None = None
anchors: tuple[Anchor, ...] = ()
draft: MelodicDraft | None = None
edits: tuple[DraftEdit, ...] = ()
initial: MelodicDraft | None = None
refined: MelodicDraft | None = None
routes: tuple[RouteCandidate, ...] = ()
before_enclosure: MelodicDraft | None = None
before_rhythm: MelodicDraft | None = None
evaluation: object | None = None
rhythm_groups: tuple = ()
@dataclasses.dataclass(frozen=True)
class Solo:
part: str
choices: tuple[generator.scales.selection.Choice, ...]
phrases: tuple[Phrase, ...]
tempo: fractions.Fraction
end: fractions.Fraction
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
passages: tuple[MelodicPassagePlan, ...] = ()
profile: generator.solo.profiles.Instrument | None = None
def notes(self) -> tuple[Note, ...]:
return sounding_notes(n for p in self.phrases for d in p.decisions for n in d.notes)
@functools.lru_cache(maxsize=4096)
def scale_pitch(scale: generator.scales.library.Scale, index: int, octave: int = 4) -> Pitch:
"""Index an extended scale, including negative degrees and octave crossings."""
@@ -408,3 +144,33 @@ def permitted(choices: tuple[generator.scales.selection.Choice, ...], start: fra
pitches &= choice.scale.pitches
covered = max(covered, choice.span.end)
return pitches if covered >= end else frozenset()
@dataclasses.dataclass(frozen=True)
class Phrase:
id: str
start: fractions.Fraction
end: fractions.Fraction
notes: tuple[Note, ...]
purpose: str
direction: str
ending: str
destination: Anchor | None = None
expressions: tuple[ExpressionContract, ...] = ()
groups: tuple = ()
theme: str = ''
score: float = 0.
evaluation: object = None
sources: tuple[str, ...] = ()
@dataclasses.dataclass(frozen=True)
class Solo:
part: str
choices: tuple
phrases: tuple[Phrase, ...]
tempo: fractions.Fraction
end: fractions.Fraction
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
profile: generator.solo.profiles.Instrument | None = None
def notes(self):
return sounding_notes(n for p in self.phrases for n in p.notes)
+11 -24
View File
@@ -15,7 +15,7 @@ ACTIVE = contextvars.ContextVar('composition_vocabulary', default=None)
def load(root=ROOT):
# Content, not just the directory, keys memoization. Long-lived individual
# role builds must observe edited production data without restarting Python.
names = ('roles', 'grammar', 'harmony', 'lead_ins', 'rhythms', 'melodic_vocabulary')
names = ('roles', 'solo', 'harmony', 'lead_ins', 'rhythms')
return _load(tuple((name, (Path(root)/(name+'.json')).read_text()) for name in names))
@functools.lru_cache(maxsize=16)
@@ -29,28 +29,15 @@ def _load(contents):
if len({v['id'] for v in entries}) != len(entries):
raise ValueError(f'{name}: duplicate vocabulary ID')
result[name] = value
cells=result['rhythms']['cells']+result['melodic_vocabulary']['cells']
if len({c['id'] for c in cells})!=len(cells):
raise ValueError('Duplicate rhythmic identity across production vocabularies')
for c in cells:
if c['purpose'] not in ('entrance','continue','connect','answer','arrival'):
raise ValueError(c['id']+': unknown rhythmic purpose')
paths=result['melodic_vocabulary']['chord_paths']
if len({p['id'] for p in paths})!=len(paths):raise ValueError('Duplicate chord-path operator')
for p in paths:
if p['direction'] not in ('up','down','toward') or p['members'] not in ('basic','defining'):
raise ValueError(p['id']+': invalid chord traversal')
if not p['member_steps'] or any(type(s)!=int or not 1<=abs(s)<=3 for s in p['member_steps']):
raise ValueError(p['id']+': invalid chord-member step')
if not p['prefix_attacks'] or any(n not in (2,3,4) for n in p['prefix_attacks']) or not 1<=p['max_interval']<=12:
raise ValueError(p['id']+': unsupported bounded chord prefix')
gestures=result['melodic_vocabulary']['gesture_rules']
if len({g['id'] for g in gestures})!=len(gestures):raise ValueError('Duplicate joint gesture rule')
for g in gestures:
if g['kind'] not in ('harmonic_arrival','sustained_color'):raise ValueError(g['id']+': unsupported joint gesture')
if g['kind']=='harmonic_arrival':
if not g['arrival_offsets'] or any(abs(F(v))>F(result['harmony']['anticipation_max']) for v in g['arrival_offsets']):raise ValueError(g['id']+': arrival outside bounded policy')
if not g['holds'] or any(F(v)<=0 for v in g['holds']) or F(g['release'])<0:raise ValueError(g['id']+': invalid sounding interval')
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')
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)
@@ -93,7 +80,7 @@ class RhythmCell:
def rhythm_cells(tempo, purpose=None):
limits = data('roles')['difficulty']
for cell in (*data('rhythms')['cells'],*data('melodic_vocabulary')['cells']):
for cell in (*data('rhythms')['cells'],*data('solo')['cells']):
if purpose is not None and cell['purpose'] != purpose:
continue
difficulty = cell.get('difficulty')
+34
View File
@@ -6,3 +6,37 @@ import generator.solo.sequences
@functools.cache
def library():
return generator.solo.sequences.Library(tuple(generator.solo.sequences.Sequence(f'{count}_4/{index:04}.xml', steps) for count in (2, 3, 4) for index, steps in enumerate(itertools.product((1, -1, 2, -2), repeat=count - 1))))
import copy, fractions
import generator.model, generator.parts, generator.harmony
import generator.solo.analysis, generator.solo.composer, generator.solo.types
import tests.test_musicxml, tests.scale_fixtures, tests.rhythm_fixtures
def song(meter=4, count=6, chords=('Dm7', 'G7', 'CM7')):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G'))))
first = document['staves'][0]['measures'][0]
measures = []
for index in range(count):
measure = copy.deepcopy(first)
measure.update(id=f'measure_{index + 1}', number=str(index + 1), duration=generator.model.rational(meter), meter={'groups': [{'beats': [meter], 'unit': 4}]}, time_signature=f'{meter}/4')
measure['voices'][0]['notes'] = [generator.model.make_event(f'source_{index}_{beat}', fractions.Fraction(beat), fractions.Fraction(1), 'note', [{'letter': 'cdefg'[beat % 5], 'octave': 4, 'alter': 0}]) for beat in range(meter)]
measure['harmonies'] = [{**generator.harmony.parse_symbol(chords[index % len(chords)]), 'id': f'h_{index}', 'onset': generator.model.rational(0)}] if chords else []
for harmony in measure['harmonies']:
harmony['symbol'] = generator.harmony.symbol(harmony)
measures.append(measure)
document['staves'][0]['measures'] = measures
return generator.parts.prepare_document(document)
def compose(document, part='acoustic', cutoff=None):
analysis = generator.solo.analysis.analyze(document)
measures = document['staves'][0]['measures']
end = sum(generator.model.fraction(m['duration']) for m in measures) if cutoff is None else cutoff
return generator.solo.composer.compose(analysis, measures, part, end, scales=tests.scale_fixtures.library(), sequences=library(), rhythms=tests.rhythm_fixtures.library())
def note(start,end,letter='g',alter=0,octave=4,**kwargs):
return generator.solo.types.Note(fractions.Fraction(start),fractions.Fraction(end),generator.solo.types.Pitch(letter,fractions.Fraction(alter),octave),**kwargs)
def choices(symbols,duration=4,scale=None):
from generator.scales import selection,timeline
scale=scale or tests.scale_fixtures.library().scale('major',6,'D')
return tuple(selection.Choice(timeline.ChordSpan(fractions.Fraction(i*duration),fractions.Fraction((i+1)*duration),i+1,str(i+1),fractions.Fraction(1),generator.model.key_from_fifths(-1),generator.harmony.parse_symbol(symbol)),scale,'independent fixture') for i,symbol in enumerate(symbols))
-177
View File
@@ -1,177 +0,0 @@
"""Independent musical fixtures; optional target scores are never read."""
import dataclasses
from fractions import Fraction as F
import unittest
from generator.solo import groups, scoring, types, harmonic, themes, realization, profiles
from tests.test_solo_development import note
from tests.test_solo_passages import choices
from tests.test_solo_themes import melody
import tests.solo_fixtures
import tests.rhythm_fixtures
def evaluated(notes,harmony,**kwargs):
motif=types.Motif('authored',(),F(4),(),'test only')
plan=types.PhrasePlan('test',F(),F(4),motif,'continue','thematic_recomposition',None,(41,76),F(1),F(),
(types.ActivitySpan(F(),F(4),'active',F(1),F()),),ending='connected',closure='open')
draft=types.MelodicDraft('test',F(),F(4),tuple(notes),(),(),(),(),None,'continue')
return scoring.musical(plan,draft,notes,types.ContinuationState(''),harmony,**kwargs)
class RhythmGroups(unittest.TestCase):
def test_complete_triplet_can_be_displaced_without_offsetting_binary_tail(self):
pattern=((F(1,2),F(5,6)),(F(5,6),F(7,6)),(F(7,6),F(3,2)),(F(3,2),F(5,2)))
result=groups.describe(pattern)
self.assertEqual(result[0].family,'triplet')
self.assertEqual(result[-1].family,'binary')
self.assertEqual(result[0].displacement,F(1,2))
def test_partial_triplet_does_not_drift_into_binary(self):
self.assertFalse(groups.valid(((F(),F(1,3)),(F(1,3),F(5,6)))))
self.assertFalse(groups.valid(((F(1,3),F(4,3)),)))
self.assertEqual(groups.describe(((F(1,3),F(4,3)),),imported=True)[0].family,'source')
def test_relative_rhythm_identity_does_not_count_absolute_onsets(self):
original=melody(((0,1,'c'),(1,2,'e'),(2,3,'d')))
self.assertEqual(len(set(groups.signature(original))),1)
moved=tuple(dataclasses.replace(n,start=n.start+13,end=n.end+13) for n in original)
self.assertEqual(groups.signature(original),groups.signature(moved))
def test_broadening_never_cuts_inside_tuplet(self):
pattern=((F(),F(1,3)),(F(1,3),F(2,3)),(F(2,3),F(1)))
self.assertEqual(groups.broaden(pattern,F(1,2),F(4)),())
class EvaluationContract(unittest.TestCase):
def test_sum_and_configured_weights_are_authoritative(self):
h=harmonic.Harmony(choices(('C7',)),profiles.Instrument(reference=48))
ns=(note(0,1,'g'),note(2,3,'f'),note(3,4,'e'))
base=evaluated(ns,h,travel=2000)
self.assertTrue(base.valid)
self.assertAlmostEqual(base.total,sum(base.contributions.values()))
for key,value in base.raw.items():
changed=evaluated(ns,h,travel=2000,weights={**base.weights,key:base.weights[key]+1})
self.assertAlmostEqual(changed.total-base.total,value)
self.assertGreater(base.raw['activity'],0)
self.assertGreater(base.raw['harmony'],0)
self.assertGreater(base.raw['register'],0)
def test_ties_are_partition_invariant_and_silence_cannot_win(self):
h=harmonic.Harmony(choices(('C7',)))
whole=(note(0,4,'e',event_id='a'),)
split=(note(0,2,'e',event_id='a'),note(2,4,'e',event_id='a',continuation=True))
self.assertEqual(evaluated(whole,h),evaluated(split,h))
self.assertFalse(evaluated((),h).valid)
def test_computational_end_is_not_a_required_chord_member(self):
h=harmonic.Harmony(choices(('C7',)))
ns=(note(0,1,'g'),note(1,2,'e'),note(2,3,'f'),note(3,4,'a'))
self.assertTrue(evaluated(ns,h).valid)
class PendingTreatments(unittest.TestCase):
def test_complete_route_can_cross_a_search_frontier(self):
from tests.test_independent_generation import TypedExpressionContracts
fixture=TypedExpressionContracts();fixture.setUp();notes,contract=fixture.make()
partial=fixture.h.evaluate(notes[:2],until=F(2),complete=False,expressions=(contract,))
self.assertTrue(partial.valid,partial.error)
self.assertEqual(partial.pending,(contract,))
self.assertFalse(fixture.h.evaluate(notes[:2],until=F(3),expressions=(contract,)).valid)
complete=fixture.h.evaluate(notes,until=F(4),complete=True,expressions=(contract,))
self.assertTrue(complete.valid);self.assertFalse(complete.pending)
class Reachability(unittest.TestCase):
def test_all_development_operations_are_requested(self):
notes=melody(((0,1,'c'),(1,2,'e'),(2,3,'d'),(3,4,'e')))
home=types.RealizedMotif('home','source',notes,F(4),'introduce','contour',signature=notes)
state=types.ContinuationState('',home=home,notes=notes,last=notes[-1])
ops=themes.operation_choices(home,state,F(4),F(4),False)
self.assertTrue({'answer','extend','fragment','continue','return'}.issubset(ops))
h=harmonic.Harmony(choices(('CM7','CM7')))
context=realization.SearchContext()
for op in ops:
candidates=themes.unit_candidates(themes.home_reference(home,op),F(4),F(8),state,h,
tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',context,home=home,operation=op)
self.assertTrue(candidates,op)
self.assertGreater(context.diagnostics['operations'][op]['valid'],0)
if __name__=='__main__':unittest.main()
class InputFirewall(unittest.TestCase):
def test_read_write_handles_cannot_bypass_boundary(self):
import tempfile
from pathlib import Path
from generator.input_audit import composition
with tempfile.TemporaryDirectory() as folder:
forbidden=Path(folder)/'target.xml';forbidden.write_text('not musical input')
for mode in ('r','r+'):
with self.assertRaisesRegex(ValueError,'Forbidden composition input'):
with composition(Path('data'),'electric_solo'):
with forbidden.open(mode) as stream:stream.read()
class RetrievalIntegrity(unittest.TestCase):
def test_diversity_optimization_preserves_stable_old_order(self):
from generator.solo.sequences import diverse
values=((3,'b'),(1,'a'),(1,'b'),(1,'a'),(2,'c'),(4,'a'))
def original(items,limit):
families={}
for item in sorted(items,key=lambda v:v[0]):families.setdefault(item[1],[]).append(item)
out=[];i=0
while len(out)<limit:
layer=[g[i] for g in families.values() if len(g)>i]
if not layer:break
out.extend(sorted(layer,key=lambda v:v[0])[:limit-len(out)]);i+=1
return tuple(out)
for limit in range(9):
self.assertEqual(diverse(values,lambda v:v[0],lambda v:v[1],limit),original(values,limit))
def test_seed_cutoff_preserves_different_feature_contracts(self):
from generator.solo import analysis
from tests.test_solo import song
seeds=themes.seeds(analysis.analyze(song(count=12)),F(4),'independent-test')
self.assertGreater(len({s.treatment for s in seeds}),1)
class NonzeroContributions(unittest.TestCase):
def test_rhythm_destination_repetition_are_operational(self):
from generator.solo import phrasing
h=harmonic.Harmony(choices(('C7',)),profiles.Instrument(reference=55))
ns=(note(0,F(1,2),'g'),note(1,F(3,2),'c',octave=5),note(3,4,'e'))
motif=types.Motif('fixture',(),F(4),(),'independently authored')
plan=types.PhrasePlan('fixture',F(),F(4),motif,'continue','thematic_recomposition',None,(41,76),F(1),F(),
(types.ActivitySpan(F(),F(4),'active',F(1),F()),),ending='connected',closure='open')
anchor=types.Anchor('arrival',F(3),F(3),ns[-1].pitch,h.members(F(3),F(4)),(41,76),F(1),'intermediate',h.at(F(3)).chord,hold_until=F(4))
draft=types.MelodicDraft('fixture',F(),F(4),ns,(),(),(),(anchor,),None,'continue')
old=tuple(dataclasses.replace(n,start=n.start-4,end=n.end-4) for n in ns)
incoming=types.ContinuationState('',history=(phrasing.heard(old,'continue'),))
base=scoring.musical(plan,draft,ns,incoming,h)
self.assertTrue(base.valid,base.error)
for key in ('rhythm','destination','repetition'):
self.assertGreater(base.raw[key],0,key)
changed=scoring.musical(plan,draft,ns,incoming,h,weights={**base.weights,key:0})
self.assertAlmostEqual(base.total-changed.total,base.contributions[key])
class PreparedSuspensions(unittest.TestCase):
def test_preparation_and_delayed_resolution_are_required(self):
h=harmonic.Harmony(choices(('Gm7','C7')))
held=note(F(5,2),F(9,2),'f',event_id='held')
target=note(F(9,2),6,'e',event_id='resolution')
contract=types.ExpressionContract('prepared','suspension','prepared_step_down',(held,),target,'C7',boundary=F(4))
self.assertTrue(h.evaluate((held,target),expressions=(contract,)).valid)
self.assertFalse(h.evaluate((held,target)).valid)
changed=dataclasses.replace(held,start=F(15,4))
self.assertFalse(h.evaluate((changed,target),expressions=(dataclasses.replace(contract,events=(changed,)),)).valid)
self.assertFalse(h.evaluate((held,),expressions=(contract,)).valid)
def test_suspension_rule_is_reachable_from_provisional_phrase(self):
from generator.solo import expressions
h=harmonic.Harmony(choices(('Gm7','C7')))
ns=(note(3,4,'f',event_id='held'),note(4,6,'e',event_id='resolution'))
motif=types.Motif('fixture',ns,F(8),(),'independent suspension')
plan=types.PhrasePlan('fixture',F(),F(8),motif,'continue','thematic_recomposition',None,(41,76),F(1),F(),ending='connected',closure='open')
draft=types.MelodicDraft('fixture',F(),F(8),ns,(),(),(),(),None,'continue')
phrase=types.Phrase(plan,(),0,draft=draft)
candidates=[(notes,c) for notes,c in expressions.proposals(phrase,h) if c.kind=='suspension']
self.assertTrue(candidates)
self.assertTrue(any(h.evaluate(notes,expressions=(c,)).valid for notes,c in candidates))
-20
View File
@@ -14,14 +14,11 @@ import generator.harmony
import generator.model
import generator.score
import generator.solo.analysis
import generator.solo.planning
import generator.solo.placement
import generator.solo.realization
import tests.solo_fixtures
import tests.test_acoustic_voicing
import tests.test_musicxml
import tests.test_solo
import tests.test_solo_development
def position(string, fret):
return generator.fretboard.TabPosition(string, fret, dict(generator.fretboard.STANDARD_TUNING)[string] + fret)
@@ -144,20 +141,3 @@ class FretPolicyTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'each sounding chord requires an anchor'):
generator.score.write_json(output, target)
self.assertFalse(output.exists())
def test_solo_candidates_and_anchors_are_reachable_before_placement(self):
choices = tests.test_solo_development.choices(end=32)
options = generator.solo.realization.options(choices, fractions.Fraction(), fractions.Fraction(4), (59, 84))
self.assertTrue(options)
self.assertLessEqual(max(p.number() for p in options), 76)
source = tests.test_solo.song(count=8, chords=('CM7',))
plans = generator.solo.planning.plans(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], choices, fractions.Fraction(32), 'electric', tests.solo_fixtures.library())
self.assertTrue(plans)
for plan in plans:
self.assertLessEqual(plan.register[1], 76)
self.assertTrue(plan.anchor_options)
for anchors in plan.anchor_options:
self.assertTrue(all(generator.solo.placement.positions(a.pitch.number()) for a in anchors))
motif = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4), choices=choices, register=(70, 84), seed='high')
self.assertTrue(all(generator.solo.placement.positions(n.pitch.number()) for n in motif.notes))
high = tests.test_solo_development.note(0, 1, 'f', octave=5)
self.assertIsNone(generator.solo.placement.place((high,), fractions.Fraction(120)))
+5 -5
View File
@@ -12,10 +12,10 @@ from fractions import Fraction as F
from unittest.mock import patch
from generator import production, model, vocabulary
from generator.solo import expressions, types, harmonic, placement, notation, composer, themes
from tests.test_solo import song
from tests.test_solo_passages import choices
from tests.test_solo_development import note
from generator.solo import expressions, types, harmonic, placement, notation, composer
from tests.solo_fixtures import song
from tests.solo_fixtures import choices
from tests.solo_fixtures import note
def music(result):
@@ -86,7 +86,7 @@ class IndependentRoles(unittest.TestCase):
for p in n['pitches']:p['letter']={'c':'e','d':'f','e':'g','f':'a'}[p['letter']]
a=production.generate_role(production.SourceAnalysis.read(source),'acoustic_solo',self.root)
b=production.generate_role(production.SourceAnalysis.read(changed),'acoustic_solo',self.root)
self.assertNotEqual(a['details']['phrases'][0]['draft']['notes'],b['details']['phrases'][0]['draft']['notes'])
self.assertNotEqual(a['details']['phrases'][0]['notes'],b['details']['phrases'][0]['notes'])
def test_source_tuplet_lanes_survive_assembly(self):
serial={r:production.generate_role(self.source,r,self.root) for r in production.ROLES}
-114
View File
@@ -1,114 +0,0 @@
"""Independent harmonic relationships; no optional reference reads."""
import dataclasses
from fractions import Fraction as F
import unittest
from generator.solo import gestures, harmonic, routes, types, expressions, placement, themes, realization
from tests.test_solo_passages import choices
from tests.test_solo_development import note
import tests.solo_fixtures
import tests.scale_fixtures
import tests.rhythm_fixtures
class JointArrivals(unittest.TestCase):
def fixture(self):
h=harmonic.Harmony(choices(('Dm7','G7'),scale=tests.scale_fixtures.library().scale('major',1,'C')))
ns=tuple(note(F(i,2),F(i+1,2),'d' if i<8 else 'g',event_id=str(i)) for i in range(16))
return h,ns
def test_anticipation_is_planned_before_route_and_requires_its_contract(self):
h,ns=self.fixture()
alternatives=list(gestures.proposals(ns,h,F(),F(8)))
p=next(p for p in alternatives if p.contracts and p.contracts[0].kind=='anticipation' and p.contracts[0].target.pitch.letter=='b' and p.contracts[0].target.end-p.contracts[0].target.start>=2)
result=routes.candidates(p.notes,(),h,tests.solo_fixtures.library(),(41,76),expressions=p.contracts)
self.assertTrue(result)
for r in result:
bound=p.bind(r.notes)
self.assertTrue(h.evaluate(r.notes,expressions=bound,complete=True).valid)
self.assertFalse(h.evaluate(r.notes,complete=True).valid)
self.assertLess(bound[0].target.start,F(4));self.assertGreater(bound[0].target.end,F(4))
self.assertIsNotNone(placement.place(r.notes,F(120)))
def test_approach_and_anticipation_share_exact_future_target(self):
h,ns=self.fixture()
base=next(p for p in gestures.proposals(ns,h,F(),F(8)) if p.contracts and p.contracts[0].kind=='anticipation')
proposals=list(gestures.approach(base,h,F()))
self.assertTrue(proposals)
realized=[]
for p in proposals:
for r in routes.candidates(p.notes,(),h,tests.solo_fixtures.library(),(41,76),expressions=p.contracts):
bound=p.bind(r.notes);self.assertTrue(h.evaluate(r.notes,expressions=bound,complete=True).valid)
self.assertFalse(h.evaluate(r.notes,expressions=bound[1:],complete=True).valid)
realized.append((r,bound))
self.assertTrue(realized)
r,contracts=realized[0];target=contracts[0].target
changed=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=n.pitch.octave+1)) if n.event_id==target.event_id else n for n in r.notes)
self.assertFalse(h.evaluate(changed,expressions=contracts,complete=True).valid)
def test_on_time_and_delayed_arrivals_are_real_alternatives(self):
h,ns=self.fixture();ps=list(gestures.proposals(ns,h,F(),F(8)))
offsets={p.notes[next(i for i,n in enumerate(p.notes) if n.role=='arrival')].start-4 for p in ps if p.purpose.startswith('harmonic_arrival')}
self.assertEqual(offsets,{F(-1,2),F(),F(1,2)})
def test_proposal_cache_preserves_music_and_restores_each_callers_provenance(self):
h,ns=self.fixture()
direct=gestures._select(ns,h,F(),F(8))
cached=gestures.select(ns,h,F(),F(8))
self.assertEqual(direct,cached)
changed=tuple(dataclasses.replace(n,event_id='caller:'+n.event_id,sources=('new-source',)) for n in ns)
self.assertEqual(gestures.select(changed,h,F(),F(8)),gestures._select(changed,h,F(),F(8)))
self.assertEqual(len(h.gesture_cache),1)
def test_route_cache_preserves_contract_conditioned_realization(self):
h,ns=self.fixture()
p=next(p for p in gestures.proposals(ns,h,F(),F(8)) if p.contracts and p.contracts[0].kind=='anticipation')
direct=routes._planned_candidates(p.notes,(),h,(41,76),'test',p.contracts)
cached=routes.planned_candidates(p.notes,(),h,(41,76),'test',p.contracts)
self.assertTrue(cached)
self.assertEqual(cached,direct)
self.assertEqual(cached,routes.planned_candidates(p.notes,(),h,(41,76),'test',p.contracts))
def test_color_is_a_planned_route_destination_not_a_blanket_permission(self):
h=harmonic.Harmony(choices(('CM7','CM7'),scale=tests.scale_fixtures.library().scale('major',1,'C')))
ns=(note(0,1,'c',event_id='a'),note(1,3,'e',event_id='b'),note(3,4,'g',event_id='c'))
p=next(p for p in gestures.proposals(ns,h,F(),F(4)) if p.contracts and p.contracts[0].rule=='major_ninth' and p.contracts[0].events[0].end-p.contracts[0].events[0].start>=2)
rs=routes.candidates(p.notes,(),h,tests.solo_fixtures.library(),(41,76),expressions=p.contracts)
self.assertTrue(rs)
self.assertFalse(h.evaluate(rs[0].notes).valid)
self.assertTrue(h.evaluate(rs[0].notes,expressions=p.bind(rs[0].notes),complete=True).valid)
def test_independent_transpositions_and_harmonic_rhythms(self):
for tonic,symbols in [('C',('Dm7','G7')),('F',('Gm7','C7')),('G',('Am7','D7'))]:
for length in (3,4):
h=harmonic.Harmony(choices(symbols,duration=length,scale=tests.scale_fixtures.library().scale('major',1,tonic)))
ns=tuple(note(F(i,2),F(i+1,2),'g',event_id=str(i)) for i in range(length*4))
ps=[p for p in gestures.proposals(ns,h,F(),F(length*2)) if p.contracts and p.contracts[0].kind=='anticipation']
self.assertTrue(ps,(tonic,length))
self.assertTrue(any(routes.candidates(p.notes,(),h,tests.solo_fixtures.library(),(41,76),expressions=p.contracts) for p in ps),(tonic,length))
def test_closed_hold_cannot_be_consumed_by_a_joint_action(self):
h,ns=self.fixture();target=ns[-1]
ending=types.Anchor('close',F(6),F(6),target.pitch,frozenset({7}),(41,76),F(2),'terminal','G7',hold_until=F(8))
for p in gestures.proposals(ns,h,F(),F(8),ending):
self.assertFalse(any(n.role=='arrival' and n.end>6 for n in p.notes))
def test_minor_sixth_is_bounded_stable_color_not_required_neighbor_resolution(self):
h=harmonic.Harmony(choices(('Dm','Dm')))
n=note(0,3,'b',octave=3,event_id='color')
c=types.ExpressionContract('sixth','color','minor_sixth',(n,),n,'Dm')
self.assertTrue(h.evaluate((n,),expressions=(c,),complete=True).valid)
self.assertFalse(h.evaluate((n,),complete=True).valid)
too_long=dataclasses.replace(n,end=F(5));bad=dataclasses.replace(c,events=(too_long,),target=too_long)
self.assertFalse(h.evaluate((too_long,),expressions=(bad,),complete=True).valid)
def test_whole_tone_enclosure_neighbor_must_actually_be_mapped(self):
h=harmonic.Harmony(choices(('C7',),scale=tests.scale_fixtures.library().scale('major',5,'C')))
target=note(2,4,'g',event_id='target')
upper=note(1,F(3,2),'a',event_id='upper');lower=note(F(3,2),2,'f',alter=1,event_id='lower')
c=types.ExpressionContract('enclosure','enclosure','mapped_upper_lower',(upper,lower),target,'C7')
self.assertTrue(h.evaluate((upper,lower,target),expressions=(c,),complete=True).valid)
altered=harmonic.Harmony(choices(('C7',),scale=tests.scale_fixtures.library().scale('harmonic_minor',5,'C')))
self.assertFalse(altered.evaluate((upper,lower,target),expressions=(c,),complete=True).valid)
if __name__=='__main__':unittest.main()
+130
View File
@@ -0,0 +1,130 @@
"""General, independently authored capabilities; no optional reference inputs."""
import copy,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
def builder(symbols=('C7','FM7','Dm7','G7'),seed='independent',count=8):
source=fixtures.song(count=count,chords=symbols);a=analysis.analyze(source);ms=source['staves'][0]['measures']
end=sum(model.fraction(m['duration']) for m in ms)
cs=selection.select(timeline.read(source['staves'][0]),timeline.Solo(F(),end,'test'),scale_fixtures.library())
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_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_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
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())
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))
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):
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))
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_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_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))
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_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))
ns=(dataclasses.replace(a,end=F(3)),dataclasses.replace(a,start=F(3),event_id='again'))
self.assertFalse(harmonic.anchors_valid((anchor,),ns,F(4),complete=True,closure='closed')[0])
self.assertTrue(harmonic.anchors_valid((anchor,),(a,),F(4),complete=True,closure='closed')[0])
if __name__=='__main__':unittest.main()
+72
View File
@@ -0,0 +1,72 @@
import dataclasses, unittest
from fractions import Fraction as F
from generator.solo import types, harmonic, groups
from tests.solo_fixtures import note,choices
def melody(rows):return tuple(note(a,b,p,event_id=str(i)) for i,(a,b,p) in enumerate(rows))
class RhythmGroups(unittest.TestCase):
def test_complete_triplet_can_be_displaced_without_offsetting_binary_tail(self):
pattern = ((F(1, 2), F(5, 6)), (F(5, 6), F(7, 6)), (F(7, 6), F(3, 2)), (F(3, 2), F(5, 2)))
result = groups.describe(pattern)
self.assertEqual(result[0].family, 'triplet')
self.assertEqual(result[-1].family, 'binary')
self.assertEqual(result[0].displacement, F(1, 2))
def test_partial_triplet_does_not_drift_into_binary(self):
self.assertFalse(groups.valid(((F(), F(1, 3)), (F(1, 3), F(5, 6)))))
self.assertFalse(groups.valid(((F(1, 3), F(4, 3)),)))
self.assertEqual(groups.describe(((F(1, 3), F(4, 3)),), imported=True)[0].family, 'source')
def test_relative_rhythm_identity_does_not_count_absolute_onsets(self):
original = melody(((0, 1, 'c'), (1, 2, 'e'), (2, 3, 'd')))
self.assertEqual(len(set(groups.signature(original))), 1)
moved = tuple((dataclasses.replace(n, start=n.start + 13, end=n.end + 13) for n in original))
self.assertEqual(groups.signature(original), groups.signature(moved))
def test_broadening_never_cuts_inside_tuplet(self):
pattern = ((F(), F(1, 3)), (F(1, 3), F(2, 3)), (F(2, 3), F(1)))
self.assertEqual(groups.broaden(pattern, F(1, 2), F(4)), ())
class PendingTreatments(unittest.TestCase):
def test_complete_route_can_cross_a_search_frontier(self):
from tests.test_independent_generation import TypedExpressionContracts
fixture = TypedExpressionContracts()
fixture.setUp()
notes, contract = fixture.make()
partial = fixture.h.evaluate(notes[:2], until=F(2), complete=False, expressions=(contract,))
self.assertTrue(partial.valid, partial.error)
self.assertEqual(partial.pending, (contract,))
self.assertFalse(fixture.h.evaluate(notes[:2], until=F(3), expressions=(contract,)).valid)
complete = fixture.h.evaluate(notes, until=F(4), complete=True, expressions=(contract,))
self.assertTrue(complete.valid)
self.assertFalse(complete.pending)
class InputFirewall(unittest.TestCase):
def test_read_write_handles_cannot_bypass_boundary(self):
import tempfile
from pathlib import Path
from generator.input_audit import composition
with tempfile.TemporaryDirectory() as folder:
forbidden = Path(folder) / 'target.xml'
forbidden.write_text('not musical input')
for mode in ('r', 'r+'):
with self.assertRaisesRegex(ValueError, 'Forbidden composition input'):
with composition(Path('data'), 'electric_solo'):
with forbidden.open(mode) as stream:
stream.read()
class PreparedSuspensions(unittest.TestCase):
def test_preparation_and_delayed_resolution_are_required(self):
h = harmonic.Harmony(choices(('Gm7', 'C7')))
held = note(F(5, 2), F(9, 2), 'f', event_id='held')
target = note(F(9, 2), 6, 'e', event_id='resolution')
contract = types.ExpressionContract('prepared', 'suspension', 'prepared_step_down', (held,), target, 'C7', boundary=F(4))
self.assertTrue(h.evaluate((held, target), expressions=(contract,)).valid)
self.assertFalse(h.evaluate((held, target)).valid)
changed = dataclasses.replace(held, start=F(15, 4))
self.assertFalse(h.evaluate((changed, target), expressions=(dataclasses.replace(contract, events=(changed,)),)).valid)
self.assertFalse(h.evaluate((held,), expressions=(contract,)).valid)
+4 -35
View File
@@ -8,10 +8,10 @@ import generator.arrange.form
import generator.model
import generator.parts
import generator.solo.arrangement
from generator.solo import analysis, profiles, placement, themes, types, harmonic, gesture_rhythm, phrasing
from tests.test_solo import song
from tests.test_solo_development import note
from tests.test_solo_passages import choices
from generator.solo import analysis, profiles, placement, types, harmonic
from tests.solo_fixtures import song
from tests.solo_fixtures import note
from tests.solo_fixtures import choices
import tests.rhythm_fixtures
@@ -75,34 +75,3 @@ class SoloInstrumentAndIdentity(unittest.TestCase):
self.assertEqual(acoustic.melody_range,electric.melody_range)
self.assertEqual(acoustic.cost(acoustic.reference+4),0)
self.assertGreater(acoustic.cost(acoustic.reference+9),0)
def test_return_keeps_opening_instead_of_later_largest_turn(self):
notes=tuple(note(i,i+1,p,event_id=str(i)) for i,p in enumerate(('d','f','g','f','c','b')))
home=types.RealizedMotif('home','seed',notes,F(6),'introduce','contour',signature=notes[:4])
reference=themes.home_reference(home,'return')
self.assertEqual(reference.notes,notes[:4])
self.assertEqual(reference.treatment,'return')
self.assertEqual(len(themes.home_reference(home,'develop_contour').notes),3)
def test_endings_distinguish_local_cadence_and_handoff(self):
harmony=harmonic.Harmony(choices(('Em7b5','A7','Dm','Dm')))
self.assertEqual(phrasing.ending_effect(F(2),harmony),'local_settle')
self.assertEqual(phrasing.ending_effect(F(9),harmony),'cadential_arrival')
self.assertEqual(phrasing.ending_effect(F(15),harmony,True),'handoff')
anchor=types.Anchor('comma',F(1),F(1),note(0,1,'g').pitch,frozenset({7}),(41,76),F(2),'terminal','Em7b5',hold_until=F(3))
self.assertEqual(set(phrasing.next_destination(anchor,harmony)),{2,5,9})
def test_rhythm_revision_connects_without_changing_attacks_or_arrival(self):
notes=(note(0,1,'g',event_id='a'),note(F(3,2),2,'e',event_id='b'),note(F(5,2),3,'g',event_id='c'),note(3,6,'e',event_id='end'))
anchor=types.Anchor('end',F(3),F(3),notes[-1].pitch,frozenset({4}),(41,76),F(3),'terminal','C7',hold_until=F(6))
draft=types.MelodicDraft('gesture',F(),F(6),notes,(),(),(),(anchor,),None,'develop',sustained_tails=(types.SustainedTail(F(3),F(6),64),))
revised,edits=gesture_rhythm.revise(draft,harmonic.Harmony(choices(('C7','C7'))))
self.assertTrue(edits)
self.assertEqual(tuple(n.pitch for n in revised.notes),tuple(n.pitch for n in notes))
self.assertEqual(revised.notes[-1],notes[-1])
self.assertLess(gesture_rhythm.fragmentation(revised.notes),gesture_rhythm.fragmentation(notes))
protected=dataclasses.replace(draft,protected=tuple(n.event_id for n in notes))
self.assertEqual(gesture_rhythm.revise(protected,harmonic.Harmony(choices(('C7','C7'))))[0].notes,notes)
if __name__=='__main__':unittest.main()
+160 -235
View File
@@ -7,7 +7,6 @@ import pathlib
import tempfile
import unittest
import unittest.mock
import generator.__main__
import generator.arrange.generate
import generator.fretboard
@@ -24,8 +23,6 @@ import generator.solo.arrangement
import generator.solo.composer
import generator.solo.notation
import generator.solo.placement
import generator.solo.planning
import generator.solo.realization
import generator.solo.report
import generator.solo.sequences
import generator.solo.types
@@ -35,240 +32,168 @@ import tests.solo_fixtures
import tests.test_musicxml
def song(meter=4, count=6, chords=('Dm7', 'G7', 'CM7')):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G'))))
first = document['staves'][0]['measures'][0]
measures = []
for index in range(count):
measure = copy.deepcopy(first)
measure.update(id=f'measure_{index + 1}', number=str(index + 1), duration=generator.model.rational(meter), meter={'groups': [{'beats': [meter], 'unit': 4}]}, time_signature=f'{meter}/4')
measure['voices'][0]['notes'] = [generator.model.make_event(f'source_{index}_{beat}', fractions.Fraction(beat), fractions.Fraction(1), 'note', [{'letter': 'cdefg'[beat % 5], 'octave': 4, 'alter': 0}]) for beat in range(meter)]
measure['harmonies'] = [{**generator.harmony.parse_symbol(chords[index % len(chords)]), 'id': f'h_{index}', 'onset': generator.model.rational(0)}] if chords else []
for harmony in measure['harmonies']:
harmony['symbol'] = generator.harmony.symbol(harmony)
measures.append(measure)
document['staves'][0]['measures'] = measures
return generator.parts.prepare_document(document)
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join((tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G')))))
first = document['staves'][0]['measures'][0]
measures = []
for index in range(count):
measure = copy.deepcopy(first)
measure.update(id=f'measure_{index + 1}', number=str(index + 1), duration=generator.model.rational(meter), meter={'groups': [{'beats': [meter], 'unit': 4}]}, time_signature=f'{meter}/4')
measure['voices'][0]['notes'] = [generator.model.make_event(f'source_{index}_{beat}', fractions.Fraction(beat), fractions.Fraction(1), 'note', [{'letter': 'cdefg'[beat % 5], 'octave': 4, 'alter': 0}]) for beat in range(meter)]
measure['harmonies'] = [{**generator.harmony.parse_symbol(chords[index % len(chords)]), 'id': f'h_{index}', 'onset': generator.model.rational(0)}] if chords else []
for harmony in measure['harmonies']:
harmony['symbol'] = generator.harmony.symbol(harmony)
measures.append(measure)
document['staves'][0]['measures'] = measures
return generator.parts.prepare_document(document)
def compose(document, part='acoustic', cutoff=None):
analysis = generator.solo.analysis.analyze(document)
measures = document['staves'][0]['measures']
end = sum(generator.model.fraction(m['duration']) for m in measures) if cutoff is None else cutoff
return generator.solo.composer.compose(analysis, measures, part, end, scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library())
analysis = generator.solo.analysis.analyze(document)
measures = document['staves'][0]['measures']
end = sum((generator.model.fraction(m['duration']) for m in measures)) if cutoff is None else cutoff
return generator.solo.composer.compose(analysis, measures, part, end, scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library())
class SoloTests(unittest.TestCase):
def setUp(self):
tests.production_fixtures.install(self)
def test_catalog_intervals_and_negative_scale_indices(self):
library = generator.solo.sequences.load_library(pathlib.Path('data/sequences'))
self.assertEqual({k: len(v) for k, v in library.paths.items()}, {2: 14, 3: 196, 4: 2744})
self.assertEqual(len({s.steps for s in library.paths[4]}), 2744)
self.assertTrue(all(s.offsets()[-1] == 0 for s in library.connecting(3, 0)))
scale = tests.scale_fixtures.library().scale('major', 6, 'D')
pitches = [generator.solo.types.scale_pitch(scale, i) for i in (2, 3, 5, 4)]
self.assertEqual([p.label() for p in pitches], ['F4', 'G4', 'Bb4', 'A4'])
self.assertEqual(generator.solo.types.scale_pitch(scale, -1).label(), 'C4')
self.assertEqual(generator.solo.types.scale_pitch(scale, -8).label(), 'C3')
self.assertEqual(generator.solo.types.scale_pitch(scale, 7).label(), 'D5')
diminished = tests.scale_fixtures.library().scale('harmonic_minor', 7, 'C')
self.assertEqual(generator.solo.types.scale_pitch(diminished, 6).label(), 'Bbb4')
def test_invalid_sequences_fail_with_source(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
with self.assertRaisesRegex(ValueError, 'empty or missing'):
generator.solo.sequences.load_library(root)
path = root / '2_4/0001.xml'
path.parent.mkdir()
path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
with self.assertRaisesRegex(ValueError, '0001.xml.*2/4'):
generator.solo.sequences.load_library(root)
for steps in ((0,), (8,), (), (1, 1, 1, 1)):
with self.assertRaisesRegex(ValueError, 'invalid'):
generator.solo.sequences.Library((generator.solo.sequences.Sequence('bad', steps),))
def test_ties_are_one_attack_but_repeated_notes_are_two(self):
content = tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>')
content += tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="stop"/>')
content += tests.test_musicxml.xml_note(duration=2, kind='half')
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
attacks = generator.solo.analysis.attacks(document)
self.assertEqual([(n.start, n.end) for n in attacks], [(0, 2), (2, 4)])
self.assertEqual(len(attacks[0].sources), 2)
self.assertEqual(attacks[0].pitch, attacks[1].pitch)
def test_held_pitch_checked_across_every_collection(self):
document = song(count=2, chords=('CM7', 'DmM7'))
choices = generator.scales.selection.select(generator.scales.timeline.read(document['staves'][0]), generator.scales.timeline.Solo(0, 8, 'solo'), tests.scale_fixtures.library())
self.assertIn(0, generator.solo.types.permitted(choices, 0, 4))
self.assertNotIn(0, generator.solo.types.permitted(choices, 3, 5))
self.assertIn(2, generator.solo.types.permitted(choices, 3, 5))
self.assertFalse(generator.solo.types.permitted(choices, 7, 9))
def test_targets_preserve_suspensions_alterations_and_extensions(self):
for symbol, present, absent in (('Csus', 5, 4), ('Cm7b5', 6, 7), ('CmM7', 11, 10), ('C7b9', 1, 2)):
pitches = generator.solo.planning.target_pitches(generator.harmony.parse_symbol(symbol), False)
self.assertIn(present, pitches)
self.assertNotIn(absent, pitches)
def test_compose_all_meters_without_stretching(self):
for meter in (2, 3, 4, 5):
with self.subTest(meter=meter):
source = song(meter, 3)
result = compose(source)
generator.solo.composer.validate(result)
self.assertEqual(result.end, meter * 3)
def setUp(self):
tests.production_fixtures.install(self)
def test_catalog_intervals_and_negative_scale_indices(self):
library = generator.solo.sequences.load_library(pathlib.Path('data/sequences'))
self.assertEqual({k: len(v) for k, v in library.paths.items()}, {2: 14, 3: 196, 4: 2744})
self.assertEqual(len({s.steps for s in library.paths[4]}), 2744)
self.assertTrue(all((s.offsets()[-1] == 0 for s in library.connecting(3, 0))))
scale = tests.scale_fixtures.library().scale('major', 6, 'D')
pitches = [generator.solo.types.scale_pitch(scale, i) for i in (2, 3, 5, 4)]
self.assertEqual([p.label() for p in pitches], ['F4', 'G4', 'Bb4', 'A4'])
self.assertEqual(generator.solo.types.scale_pitch(scale, -1).label(), 'C4')
self.assertEqual(generator.solo.types.scale_pitch(scale, -8).label(), 'C3')
self.assertEqual(generator.solo.types.scale_pitch(scale, 7).label(), 'D5')
diminished = tests.scale_fixtures.library().scale('harmonic_minor', 7, 'C')
self.assertEqual(generator.solo.types.scale_pitch(diminished, 6).label(), 'Bbb4')
def test_invalid_sequences_fail_with_source(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
with self.assertRaisesRegex(ValueError, 'empty or missing'):
generator.solo.sequences.load_library(root)
path = root / '2_4/0001.xml'
path.parent.mkdir()
path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
with self.assertRaisesRegex(ValueError, '0001.xml.*2/4'):
generator.solo.sequences.load_library(root)
for steps in ((0,), (8,), (), (1, 1, 1, 1)):
with self.assertRaisesRegex(ValueError, 'invalid'):
generator.solo.sequences.Library((generator.solo.sequences.Sequence('bad', steps),))
def test_ties_are_one_attack_but_repeated_notes_are_two(self):
content = tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>')
content += tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="stop"/>')
content += tests.test_musicxml.xml_note(duration=2, kind='half')
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
attacks = generator.solo.analysis.attacks(document)
self.assertEqual([(n.start, n.end) for n in attacks], [(0, 2), (2, 4)])
self.assertEqual(len(attacks[0].sources), 2)
self.assertEqual(attacks[0].pitch, attacks[1].pitch)
def test_held_pitch_checked_across_every_collection(self):
document = song(count=2, chords=('CM7', 'DmM7'))
choices = generator.scales.selection.select(generator.scales.timeline.read(document['staves'][0]), generator.scales.timeline.Solo(0, 8, 'solo'), tests.scale_fixtures.library())
self.assertIn(0, generator.solo.types.permitted(choices, 0, 4))
self.assertNotIn(0, generator.solo.types.permitted(choices, 3, 5))
self.assertIn(2, generator.solo.types.permitted(choices, 3, 5))
self.assertFalse(generator.solo.types.permitted(choices, 7, 9))
def test_no_harmony_is_silence_and_conflict_is_an_error(self):
result = compose(song(chords=()))
self.assertFalse(result.notes())
with self.assertRaisesRegex(ValueError, 'source measure 1.*No exact match'):
compose(song(chords=('A13b9',)))
def test_microtones_are_not_rounded_into_the_catalog(self):
with self.assertRaisesRegex(ValueError, 'semitones'):
generator.solo.types.Pitch('c', fractions.Fraction(1, 2), 4)
def test_pickup_cutoff_is_reserved_before_composition(self):
result = compose(song(count=6), 'electric', fractions.Fraction(43, 2))
self.assertTrue(all((n.end <= fractions.Fraction(43, 2) for n in result.notes())))
self.assertEqual(result.phrases[-1].end, fractions.Fraction(43, 2))
def test_time_aware_placement_does_not_penalize_absolute_frets(self):
p = generator.solo.types.Pitch('c', fractions.Fraction(), 4)
before = generator.solo.types.Note(0, 1, p, position=generator.fretboard.TabPosition(2, 1, 60))
after = generator.solo.types.Note(1, 2, p, position=generator.fretboard.TabPosition(2, 6, 65))
self.assertGreater(generator.solo.placement.travel(before, after, fractions.Fraction(180)), generator.solo.placement.travel(before, after, fractions.Fraction(90)))
shifted = dataclasses.replace(before, position=generator.fretboard.TabPosition(2, 8, 67))
current = dataclasses.replace(after, position=generator.fretboard.TabPosition(2, 13, 72))
self.assertEqual(generator.solo.placement.travel(before, after, fractions.Fraction(120)), generator.solo.placement.travel(shifted, current, fractions.Fraction(120)))
def test_notation_ties_cross_bars_and_keep_string_without_new_attack(self):
document = song(count=2, chords=('CM7',))
note = generator.solo.types.Note(fractions.Fraction(3), fractions.Fraction(6), generator.solo.types.Pitch('e', fractions.Fraction(), 4), position=generator.fretboard.TabPosition(2, 5, 64))
document['staves'][0]['measures'] = generator.solo.notation.measures(document['staves'][0]['measures'], (note,), 'test')
document['staves'][0]['tablature'] = True
generator.model.validate_document(document)
events = generator.playback.compile_timeline(document)['events']
self.assertEqual(len(events), 1)
self.assertEqual(generator.model.fraction(events[0]['duration']), 3)
self.assertEqual(generator.model.fraction(events[0]['pitch']), 64)
def test_rational_motif_rhythm_has_visible_tuplets_and_exact_playback(self):
document = song(count=1, chords=('CM7',))
pitch = generator.solo.types.Pitch('e', fractions.Fraction(), 4)
notes = tuple((generator.solo.types.Note(fractions.Fraction(i, 3), fractions.Fraction(i + 1, 3), pitch, position=generator.fretboard.TabPosition(2, 5, 64)) for i in range(3)))
replacement = generator.solo.notation.measures(document['staves'][0]['measures'], notes, 'triplet')
generator.solo.notation.replace_region(document, 0, replacement)
generator.model.validate_document(document)
self.assertEqual(document['staves'][0]['tuplets'][0]['note_count'], 3)
self.assertEqual(document['staves'][0]['tuplets'][0]['space_count'], 2)
timeline = generator.playback.compile_timeline(document)
self.assertEqual([generator.model.fraction(e['duration']) for e in timeline['events']], [fractions.Fraction(1, 3)] * 3)
def test_source_tempo_and_fallback_are_quarter_note_rates(self):
source = song()
self.assertEqual(generator.solo.analysis.analyze(source).tempo, 120)
source['staves'][0]['measures'][0]['tempos'] = [{'onset': generator.model.rational(0), 'written': {'type': 'half', 'dots': 0}, 'bpm': generator.model.rational(75)}]
self.assertEqual(generator.solo.analysis.analyze(source).tempo, 150)
def test_harmony_carries_into_the_solo_until_an_explicit_no_chord(self):
source = song(count=2, chords=())
source['staves'][0]['measures'][1]['harmonies'] = [{'id': 'nc', 'onset': generator.model.rational(0), 'kind': 'none', 'symbol': 'N.C.'}]
result = generator.solo.composer.compose(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], 'acoustic', 8, initial=generator.harmony.parse_symbol('CM7'), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library())
self.assertTrue(result.notes())
self.assertTrue(all(1 <= n.position.fret <= 12 for n in result.notes()))
self.assertEqual(result, compose(source))
def test_motif_recurrence_and_whole_fragment_octave_quote(self):
source = song(count=6, chords=('CM7',))
result = compose(source)
self.assertLessEqual(len({p.plan.motif.id for p in result.phrases}), 3)
self.assertTrue(all(p.draft.source_phrase for p in result.phrases))
self.assertFalse(any(d.treatment.startswith('quote') for p in result.phrases for d in p.decisions))
original = result.phrases[0].plan
explicit = dataclasses.replace(original, treatment='exact_quote', end=original.start+original.motif.duration,
target=None, anchor_options=(), gestures=(), passages=(),landmarks=(),release=fractions.Fraction(),ending='connected',closure='open',
passage_id='', passage_start=None, passage_end=None)
first = generator.solo.realization.compose(explicit, source['staves'][0]['measures'], result.choices, tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), result.tempo)
quote = next(d for d in first.decisions if d.treatment.startswith('quote'))
self.assertEqual(len(quote.notes), len(first.plan.motif.notes))
shifts = {a.pitch.number() - b.pitch.number() for a, b in zip(quote.notes, first.plan.motif.notes)}
self.assertEqual(len(shifts), 1)
self.assertEqual(next(iter(shifts)) % 12, 0)
self.assertEqual([(n.start, n.end) for n in quote.notes], [(first.plan.start + n.start, first.plan.start + n.end) for n in first.plan.motif.notes])
result = dataclasses.replace(result, phrases=(first, *result.phrases[1:]))
report = generator.solo.report.describe(result, tests.scale_fixtures.library())
self.assertIn('quote octave', report)
self.assertIn('source events:', report)
def test_no_harmony_is_silence_and_conflict_is_an_error(self):
result = compose(song(chords=()))
self.assertFalse(result.notes())
with self.assertRaisesRegex(ValueError, 'source measure 1.*No exact match'):
compose(song(chords=('A13b9',)))
def test_no_harmony_inside_a_motif_is_an_explicit_fragment(self):
source = song(count=3, chords=('CM7',))
measure = source['staves'][0]['measures'][0]
measure['harmonies'].append({'id': 'nc', 'onset': generator.model.rational(1), 'kind': 'none', 'degrees': [], 'symbol': 'N.C.'})
measure['harmonies'].append({**copy.deepcopy(measure['harmonies'][0]), 'id': 'return', 'onset': generator.model.rational(2)})
result = compose(source)
self.assertTrue(result.notes())
self.assertFalse(any(n.start < 2 and n.end > 1 for n in result.notes()))
self.assertTrue(any(c.scale is None and c.span.start == 1 and c.span.end == 2 for c in result.choices))
def test_microtones_are_not_rounded_into_the_catalog(self):
with self.assertRaisesRegex(ValueError, 'semitones'):
generator.solo.types.Pitch('c', fractions.Fraction(1, 2), 4)
def test_pickup_cutoff_is_reserved_before_composition(self):
result = compose(song(count=6), 'electric', fractions.Fraction(43, 2))
self.assertTrue(all(n.end <= fractions.Fraction(43, 2) for n in result.notes()))
self.assertEqual(result.phrases[-1].plan.end, fractions.Fraction(43, 2))
def test_search_exhaustion_does_not_claim_impossibility(self):
source = song()
with unittest.mock.patch('generator.solo.themes.phrase_candidates', return_value=()), self.assertRaisesRegex(generator.solo.realization.SearchExhausted, 'not proof'):
compose(source)
def test_time_aware_placement_does_not_penalize_absolute_frets(self):
p = generator.solo.types.Pitch('c', fractions.Fraction(), 4)
before = generator.solo.types.Note(0, 1, p, position=generator.fretboard.TabPosition(2, 1, 60))
after = generator.solo.types.Note(1, 2, p, position=generator.fretboard.TabPosition(2, 6, 65))
self.assertGreater(generator.solo.placement.travel(before, after, fractions.Fraction(180)), generator.solo.placement.travel(before, after, fractions.Fraction(90)))
shifted = dataclasses.replace(before, position=generator.fretboard.TabPosition(2, 8, 67))
current = dataclasses.replace(after, position=generator.fretboard.TabPosition(2, 13, 72))
self.assertEqual(generator.solo.placement.travel(before, after, fractions.Fraction(120)), generator.solo.placement.travel(shifted, current, fractions.Fraction(120)))
def test_notation_ties_cross_bars_and_keep_string_without_new_attack(self):
document = song(count=2, chords=('CM7',))
note = generator.solo.types.Note(fractions.Fraction(3), fractions.Fraction(6), generator.solo.types.Pitch('e', fractions.Fraction(), 4), position=generator.fretboard.TabPosition(2, 5, 64))
document['staves'][0]['measures'] = generator.solo.notation.measures(document['staves'][0]['measures'], (note,), 'test')
document['staves'][0]['tablature'] = True
generator.model.validate_document(document)
events = generator.playback.compile_timeline(document)['events']
self.assertEqual(len(events), 1)
self.assertEqual(generator.model.fraction(events[0]['duration']), 3)
self.assertEqual(generator.model.fraction(events[0]['pitch']), 64)
def test_rational_motif_rhythm_has_visible_tuplets_and_exact_playback(self):
document = song(count=1, chords=('CM7',))
pitch = generator.solo.types.Pitch('e', fractions.Fraction(), 4)
notes = tuple(generator.solo.types.Note(fractions.Fraction(i, 3), fractions.Fraction(i + 1, 3), pitch, position=generator.fretboard.TabPosition(2, 5, 64)) for i in range(3))
replacement = generator.solo.notation.measures(document['staves'][0]['measures'], notes, 'triplet')
generator.solo.notation.replace_region(document, 0, replacement)
generator.model.validate_document(document)
self.assertEqual(document['staves'][0]['tuplets'][0]['note_count'], 3)
self.assertEqual(document['staves'][0]['tuplets'][0]['space_count'], 2)
timeline = generator.playback.compile_timeline(document)
self.assertEqual([generator.model.fraction(e['duration']) for e in timeline['events']], [fractions.Fraction(1, 3)] * 3)
def test_source_tempo_and_fallback_are_quarter_note_rates(self):
source = song()
self.assertEqual(generator.solo.analysis.analyze(source).tempo, 120)
source['staves'][0]['measures'][0]['tempos'] = [{'onset': generator.model.rational(0), 'written': {'type': 'half', 'dots': 0}, 'bpm': generator.model.rational(75)}]
self.assertEqual(generator.solo.analysis.analyze(source).tempo, 150)
def test_harmony_carries_into_the_solo_until_an_explicit_no_chord(self):
source = song(count=2, chords=())
source['staves'][0]['measures'][1]['harmonies'] = [{'id': 'nc', 'onset': generator.model.rational(0), 'kind': 'none', 'symbol': 'N.C.'}]
result = generator.solo.composer.compose(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], 'acoustic', 8, initial=generator.harmony.parse_symbol('CM7'), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library())
self.assertTrue(result.notes())
self.assertTrue(all(n.end <= 4 for n in result.notes()))
def test_arrangement_preserves_head_and_vocal_and_backs_both_solos(self):
source = song(count=3, chords=('CM7',))
rhythms = tests.rhythm_fixtures.library()
plans = generator.arrange.generate.plan_arrangement(source, rhythms)
parts = {p: generator.arrange.generate._arrange_document(source, p, plans) for p in generator.parts.PART_TYPES}
before = copy.deepcopy(parts)
generator.solo.arrangement.apply(source, parts, rhythms=rhythms, scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library())
self.assertEqual(before['vocal'], parts['vocal'])
regions = generator.solo.arrangement.regions(source)
low, high = regions[3][0], regions[4][0] + regions[4][1]
for part in ('acoustic', 'electric'):
old, new = before[part]['staves'][0]['measures'], parts[part]['staves'][0]['measures']
self.assertEqual(old[:low], new[:low])
self.assertEqual(old[high:], new[high:])
support = 4 if part == 'acoustic' else 3
first, count, _, _ = regions[support]
self.assertEqual(old[first:first+count],new[first:first+count])
for section in (3, 4):
first, count, _, _ = regions[section]
self.assertTrue(any(n['pitches'] for m in new[first:first + count] for n in generator.score.measure_notes(m)))
generator.model.validate_document(parts[part])
def test_conflict_preserves_published_assets(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
(root / 'data/songs').mkdir(parents=True)
path = root / 'data/songs/conflict.xml'
path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
output = root / 'assets'
output.mkdir()
(output / 'accepted').write_text('previous build')
args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()), unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()), unittest.mock.patch('generator.solo.composer.compose', side_effect=ValueError('No exact match within the permitted catalog')):
with self.assertRaisesRegex(ValueError, 'conflict.xml.*No exact match'):
generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['accepted'])
self.assertEqual((output / 'accepted').read_text(), 'previous build')
def test_unrelated_song_does_not_change_an_accepted_solo(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
source, output = root / 'songs', root / 'assets/songs'
source.mkdir()
xml = tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>' + ''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G')))
(source / 'accepted.xml').write_text(xml)
resources = {'rhythms': tests.rhythm_fixtures.library(), 'scales': tests.scale_fixtures.library(), 'sequences': tests.solo_fixtures.library()}
generator.arrange.generate.generate_performance_scores(source, output, **resources)
before = {p: p.read_bytes() for p in root.joinpath('assets').rglob('*') if p.is_file()}
(source / 'another.xml').write_text(xml.replace('Fixture', 'Another'))
generator.arrange.generate.generate_performance_scores(source, output, **resources)
self.assertEqual(before, {p: p.read_bytes() for p in before})
def test_quote_can_reselect_a_collection_without_changing_harmony(self):
document = song(count=2, chords=('DmM7',))
timeline = generator.scales.timeline.read(document['staves'][0])
region = generator.scales.timeline.Solo(0, 8, 'solo')
retained = ((0, 2, {11}),)
result = generator.scales.selection.select(timeline, region, tests.scale_fixtures.library(), retained=retained)
self.assertTrue(all(11 in c.scale.pitches for c in result))
self.assertTrue(all(set(generator.harmony.pitch_classes(c.span.chord)) <= c.scale.pitches for c in result))
self.assertEqual([c.span.chord for c in result], [s.chord for s in timeline])
def test_pitch_adaptation_crosses_collection_without_root_restart(self):
document = song(count=2, chords=('CM7', 'DmM7'))
choices = generator.scales.selection.select(generator.scales.timeline.read(document['staves'][0]), generator.scales.timeline.Solo(0, 8, 'solo'), tests.scale_fixtures.library())
pitches = (generator.solo.types.Pitch('b', fractions.Fraction(), 3), generator.solo.types.Pitch('c', fractions.Fraction(), 4))
paths = generator.solo.realization.adapt_path(pitches, ((3, 4), (4, 5)), choices, (55, 74))
self.assertTrue(paths)
for path in paths:
self.assertTrue(generator.solo.realization.valid_notes(path, choices))
self.assertLessEqual(abs(path[1].pitch.number() - path[0].pitch.number()), 3)
def test_sequence_sources_are_packaged_and_tracked(self):
self.assertIn('"data/sequences/**/*.xml"', pathlib.Path('conanfile.py').read_text())
self.assertIn("'data/sequences/**/*.xml'", pathlib.Path('.gitea/workflows/ci.yml').read_text())
self.assertIn('data/*.xml', pathlib.Path('cmake/zero.cmake').read_text())
self.assertTrue(all((n.end <= 4 for n in result.notes())))
def test_conflict_preserves_published_assets(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
(root / 'data/songs').mkdir(parents=True)
path = root / 'data/songs/conflict.xml'
path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
output = root / 'assets'
output.mkdir()
(output / 'accepted').write_text('previous build')
args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()), unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()), unittest.mock.patch('generator.solo.composer.compose', side_effect=ValueError('No exact match within the permitted catalog')):
with self.assertRaisesRegex(ValueError, 'conflict.xml.*No exact match'):
generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['accepted'])
self.assertEqual((output / 'accepted').read_text(), 'previous build')
def test_unrelated_song_does_not_change_an_accepted_solo(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
source, output = (root / 'songs', root / 'assets/songs')
source.mkdir()
xml = tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>' + ''.join((tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G'))))
(source / 'accepted.xml').write_text(xml)
resources = {'rhythms': tests.rhythm_fixtures.library(), 'scales': tests.scale_fixtures.library(), 'sequences': tests.solo_fixtures.library()}
generator.arrange.generate.generate_performance_scores(source, output, **resources)
before = {p: p.read_bytes() for p in root.joinpath('assets').rglob('*') if p.is_file()}
(source / 'another.xml').write_text(xml.replace('Fixture', 'Another'))
generator.arrange.generate.generate_performance_scores(source, output, **resources)
self.assertEqual(before, {p: p.read_bytes() for p in before})
def test_sequence_sources_are_packaged_and_tracked(self):
self.assertIn('"data/sequences/**/*.xml"', pathlib.Path('conanfile.py').read_text())
self.assertIn("'data/sequences/**/*.xml'", pathlib.Path('.gitea/workflows/ci.yml').read_text())
self.assertIn('data/*.xml', pathlib.Path('cmake/zero.cmake').read_text())
+4 -68
View File
@@ -10,10 +10,10 @@ import unittest
from audition import reference_review, solo_compare
from generator import vocabulary, production
from generator.solo import export, gesture_rhythm, harmonic, notation, placement, routes, themes, types
from tests.test_solo import song
from tests.test_solo_development import note
from tests.test_solo_passages import choices
from generator.solo import export, harmonic, notation, placement, routes, types
from tests.solo_fixtures import song
from tests.solo_fixtures import note
from tests.solo_fixtures import choices
from tests import solo_fixtures
@@ -116,70 +116,6 @@ class ComparisonTests(unittest.TestCase):
self.assertTrue((root/'solo_xml'/'fixture-solo2.xml').is_file())
class MusicalSelectionTests(unittest.TestCase):
def test_cell_purpose_survives_loading_and_filters_eligibility(self):
cells=tuple(vocabulary.rhythm_cells(120,'arrival'))
self.assertTrue(cells)
self.assertTrue(all(c.purpose=='arrival' for c in cells))
self.assertFalse(any(c.id=='short_answer' for c in cells))
def test_connecting_rhythm_prefers_groups_to_isolated_notes(self):
joined=((F(),F(1,2)),(F(1,2),F(3,2)),(F(3,2),F(2)))
fragmented=((F(),F(1,2)),(F(1),F(3,2)),(F(2),F(5,2)))
self.assertLess(themes.rhythm_cost(joined,F(4),'connect'),themes.rhythm_cost(fragmented,F(4),'connect'))
def test_complete_signature_boundary_can_cross_barline(self):
home=SimpleNamespace(signature=(note(0,2),note(2,5)))
source=SimpleNamespace(end=F(24));ending=SimpleNamespace(earliest=F(21))
self.assertEqual(themes.unit_end(source,F(8),home,ending,F(4)),13)
def test_arpeggio_is_not_charged_for_each_nonstep_interval(self):
chord=(note(0,F(1,2),'c'),note(F(1,2),1,'e'),note(1,F(3,2),'g'))
steps=(note(0,F(1,2),'c'),note(F(1,2),1,'d'),note(1,F(3,2),'e'))
self.assertEqual(routes.path_cost(chord),routes.path_cost(steps))
h=harmonic.Harmony(choices(('CM7',)))
self.assertEqual(routes.path_family(chord,h)[0],'arpeggio')
self.assertEqual(routes.path_family(steps,h)[0],'mapped')
run=tuple(note(i,F(i+1),p) for i,p in enumerate(('c','d','e','f','g','a')))
self.assertEqual(routes.path_cost(run),0)
oscillation=tuple(note(i,F(i+1),p) for i,p in enumerate(('c','d','c','d','c')))
self.assertGreater(routes.path_cost(oscillation),0)
def test_data_driven_arpeggio_reaches_exact_destination_as_one_route(self):
h=harmonic.Harmony(choices(('CM7',)))
notes=(note(0,F(1,2),'c',event_id='first'),note(F(1,2),1,'c',event_id='middle'),note(1,F(3,2),'g',event_id='target'))
paths=routes.candidates(notes,{'target'},h,solo_fixtures.library(),(41,76))
authored=[r for r in paths if any(s.startswith('chord-path:') for s in r.sources)]
self.assertTrue(authored)
self.assertTrue(any([n.pitch.number() for n in r.notes]==[60,64,67] for r in authored))
self.assertTrue(all(r.notes[-1].pitch==notes[-1].pitch for r in paths))
self.assertTrue(all(h.evaluate(r.notes).valid for r in paths))
def test_solo_vocabulary_changes_do_not_change_accompaniment_inputs(self):
resources=vocabulary.load()
changed=copy.deepcopy(resources)
changed['melodic_vocabulary']['cells'].append({'id':'independent-test'})
for role in ('acoustic_accompaniment','electric_accompaniment','electric_melody','vocal_melody'):
self.assertEqual(production.dependencies(role,resources),production.dependencies(role,changed))
self.assertNotEqual(production.dependencies('acoustic_solo',resources),production.dependencies('acoustic_solo',changed))
def test_cross_bar_arrival_cell_remains_one_sounding_duration(self):
cell=next(c for c in vocabulary.rhythm_cells(120,'arrival') if c.id=='anticipated_sustain')
self.assertTrue(any(a<4<b for a,b in cell.pattern))
def test_shared_provisional_arrival_preserves_terminal_hold(self):
a=note(0,1,'c',event_id='a');b=note(1,2,'c',event_id='b');c=note(2,4,'e',event_id='c')
t=lambda id,start,end,operation,events:types.Transformation(id,'source','home',operation,F(start),F(end),events,(),preserves=())
draft=types.MelodicDraft('draft',F(),F(4),(a,b,c),(t('a',0,1,'introduce',('a',)),t('b',1,4,'continue',('b','c'))),(),(),(),None,'continue')
h=harmonic.Harmony(choices(('CM7',)))
revised,edits=gesture_rhythm.shared_arrival(draft,h)
self.assertEqual(len(revised.notes),2)
self.assertEqual(revised.notes[0].start,0)
self.assertEqual(revised.notes[-1],c)
self.assertEqual(edits[0].kind,'shared_arrival')
self.assertEqual(gesture_rhythm.revise(draft,h)[1][0].kind,'shared_arrival')
protected=dataclasses.replace(draft,protected=('a',))
self.assertEqual(gesture_rhythm.shared_arrival(protected,h)[0],protected)
if __name__=='__main__':unittest.main()
-169
View File
@@ -1,169 +0,0 @@
import dataclasses
import fractions
import pathlib
import unittest
import generator.harmony
import generator.model
import generator.rhythm
import generator.solo.planning
import generator.solo.analysis
import generator.solo.composer
import generator.solo.placement
import generator.solo.realization
import generator.solo.scoring
import generator.solo.types
import generator.solo.sequences
import generator.fretboard
import generator.scales.selection
import generator.scales.timeline
import tests.scale_fixtures
import tests.solo_fixtures
import tests.rhythm_fixtures
import tests.test_solo
def choices(root='C', fifths=0, end=8):
scale = tests.scale_fixtures.library().scale('major', 1, root)
span = generator.scales.timeline.ChordSpan(fractions.Fraction(), fractions.Fraction(end), 1, '1', fractions.Fraction(1), generator.model.key_from_fifths(fifths), generator.harmony.parse_symbol(root + 'M7'))
return tuple([generator.scales.selection.Choice(span, scale, 'fixture')])
def plan(intent='active'):
motif = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4))
passage = generator.solo.types.ActivitySpan(fractions.Fraction(), fractions.Fraction(8), intent, fractions.Fraction(1) if intent == 'active' else fractions.Fraction(1, 8) if intent == 'sustained_arrival' else fractions.Fraction(), fractions.Fraction(3, 4) if intent == 'active' else fractions.Fraction(1) if intent == 'sustained_arrival' else fractions.Fraction())
return generator.solo.types.PhrasePlan('fixture', passage.start, passage.end, motif, 'statement', 'thematic', None, (59, 75), passage.activity, fractions.Fraction(), (passage,))
def note(start, end, letter='g', alter=0, octave=4, **kwargs):
return generator.solo.types.Note(fractions.Fraction(start), fractions.Fraction(end), generator.solo.types.Pitch(letter, fractions.Fraction(alter), octave), **kwargs)
class ScoringTests(unittest.TestCase):
def test_silence_is_an_intent_not_a_register_advantage(self):
for root, fifths, pitch in (('C', 0, ('g', 0)), ('Db', -5, ('a', -1))):
harmony = choices(root, fifths)
passage = plan()
notes = tuple(note(i, i + 1, *pitch) for i in range(8))
self.assertFalse(generator.solo.scoring.feasible(passage, (), harmony, 8))
self.assertTrue(generator.solo.scoring.feasible(passage, notes, harmony, 8))
sounded = generator.solo.scoring.value(generator.solo.scoring.features(passage, notes, harmony))
silent = generator.solo.scoring.value(generator.solo.scoring.features(passage, (), harmony))
self.assertLess(sounded, silent)
self.assertEqual(generator.solo.scoring.features(passage, notes, harmony)['register'], 0)
def test_breath_sustain_and_activity_have_different_obligations(self):
harmony = choices()
self.assertTrue(generator.solo.scoring.feasible(plan('space'), (), harmony, 8))
self.assertFalse(generator.solo.scoring.feasible(plan('space'), (note(0, 8),), harmony, 8))
sustained = generator.solo.scoring.features(plan('sustained_arrival'), (note(0, 8),), harmony)
active = generator.solo.scoring.features(plan(), (note(0, 8),), harmony)
self.assertEqual(sustained['occupancy'], 0)
self.assertEqual(sustained['activity'], 0)
self.assertGreater(active['activity'], sustained['activity'])
self.assertTrue(generator.solo.scoring.feasible(plan(), (), harmony, 4))
def test_partition_and_ties_do_not_change_musical_evaluation(self):
whole = (note(0, 4), note(4, 8, 'a'))
split = (note(0, 2), note(2, 4, continuation=True), note(4, 6, 'a'), note(6, 8, 'a', continuation=True))
self.assertEqual(generator.solo.scoring.features(plan(), whole, choices()), generator.solo.scoring.features(plan(), split, choices()))
self.assertEqual(len(generator.solo.types.sounding_notes(split)), 2)
self.assertEqual(len(generator.solo.types.sounding_notes((note(0, 2), note(2, 4)))), 2)
def test_transposition_moves_corridor_and_notes_together(self):
original = (note(0, 2, 'c'), note(2, 4, 'e'), note(4, 8, 'g'))
moved = (note(0, 2, 'd'), note(2, 4, 'f', 1), note(4, 8, 'a'))
shifted = dataclasses.replace(plan(), register=(61, 77))
self.assertEqual(generator.solo.scoring.features(plan(), original, choices()), generator.solo.scoring.features(shifted, moved, choices('D', 2)))
def test_no_harmony_removes_activity_obligations(self):
harmony = tuple(dataclasses.replace(c, scale=None, span=dataclasses.replace(c.span, chord=None)) for c in choices())
self.assertTrue(generator.solo.scoring.feasible(plan(), (), harmony, 8))
self.assertEqual(generator.solo.scoring.value(generator.solo.scoring.features(plan(), (), harmony)), 0)
class DevelopmentTests(unittest.TestCase):
def test_connected_motion_can_cross_a_bar_and_arrive_on_a_sustain(self):
source = tests.test_solo.song(count=4, chords=('CM7',))
passage = dataclasses.replace(plan(), end=fractions.Fraction(16), treatment='contour_reference', passages=(generator.solo.types.ActivitySpan(0, 8, 'active', fractions.Fraction(2), fractions.Fraction(1)), generator.solo.types.ActivitySpan(8, 16, 'sustained_arrival', fractions.Fraction(1, 8), fractions.Fraction(1))), target=generator.solo.types.TargetEvent(fractions.Fraction(8), fractions.Fraction(8), frozenset({4}), 'CM7', 'fixture arrival'))
phrase = generator.solo.realization.compose(passage, source['staves'][0]['measures'], choices(end=16), tests.solo_fixtures.library(), generator.rhythm.load_library(pathlib.Path('data/rhythms')), fractions.Fraction(120))
notes = generator.solo.composer.phrase_notes(phrase)
self.assertGreaterEqual(sum(n.start < 8 for n in notes), 12)
# Continuity also includes a quarter-note arrival; the new policy does
# not force every successful crossing to be an eighth-note pair.
self.assertTrue(any(n.start < 4 < n.end for n in notes) or any(a.end == b.start == 4 for a, b in zip(notes, notes[1:])))
self.assertTrue(any(n.start == 8 and n.pitch.number() % 12 == 4 and n.end - n.start >= 4 for n in notes))
self.assertEqual(notes[-1].end, 16)
def test_exact_quote_cannot_silently_fragment_at_no_harmony(self):
source = tests.test_solo.song(count=2, chords=('CM7',))
passage = dataclasses.replace(plan(), treatment='exact_quote')
harmony = tuple(dataclasses.replace(c, scale=None, span=dataclasses.replace(c.span, chord=None)) for c in choices())
with self.assertRaises(generator.solo.realization.SearchExhausted):
generator.solo.realization.compose(passage, source['staves'][0]['measures'], harmony, tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), fractions.Fraction(120))
def test_dense_transformed_motif_can_fragment_at_no_harmony(self):
harmony = choices()
active = dataclasses.replace(harmony[0], span=dataclasses.replace(harmony[0].span, end=fractions.Fraction(3)))
silence = dataclasses.replace(harmony[0], scale=None, span=dataclasses.replace(harmony[0].span, start=fractions.Fraction(3), chord=None))
passage = dataclasses.replace(plan(), treatment='contour_reference')
spans = tuple((fractions.Fraction(i, 2), fractions.Fraction(i + 1, 2)) for i in range(8))
cell = generator.solo.realization.Cell(fractions.Fraction(), fractions.Fraction(4), (('eighths', spans),), passage.motif)
candidates = generator.solo.realization.candidates(cell, passage, (active, silence), tests.solo_fixtures.library())
self.assertTrue(candidates)
self.assertTrue(all(len(c.notes) == 6 and all(n.end <= 3 for n in c.notes) for c in candidates))
def test_dense_rhythms_and_chained_paths_retain_authored_timing(self):
# The real library's fully sounding eighths must survive retrieval.
rhythms = generator.rhythm.load_library(pathlib.Path('data/rhythms'))
patterns = generator.solo.realization.rhythm_patterns(rhythms, '4/4', fractions.Fraction(4), fractions.Fraction(2))
self.assertTrue(any(len(spans) == 8 and all(b - a == fractions.Fraction(1, 2) for a, b in spans) for _, spans in patterns))
sequences = tests.solo_fixtures.library()
lookup = {s.id: s for values in sequences.paths.values() for s in values}
paths = sequences.retrieve(8, 5, 1)
self.assertTrue(paths)
for path in paths:
self.assertEqual(len(path.offsets()), 8)
self.assertEqual(path.steps, tuple(step for source in path.sources for step in lookup[source].steps))
self.assertGreater(len({generator.solo.sequences.family(p.steps) for p in paths}), 2)
def test_retrieval_uses_paths_beyond_the_old_global_prefix(self):
library = tests.solo_fixtures.library()
original = {s.id for s in library.connecting(4)[:16]}
chosen = library.retrieve(4, 6, 1)
self.assertTrue(any(set(p.sources) - original for p in chosen))
self.assertTrue(any(p.offsets()[-1] == 6 for p in chosen))
self.assertEqual(chosen, library.retrieve(4, 6, 1))
def test_transformation_contracts_protect_different_features(self):
motif = generator.solo.types.Motif('source', (note(0, 1, 'c'), note(1, 2, 'd'), note(2, 3, 'e'), note(3, 4, 'c')), fractions.Fraction(4), ())
adapted = (note(0, 1, 'e'), note(1, 2, 'f'), note(2, 3, 'g'), note(3, 4, 'e'))
unrelated = (note(0, 1, 'g'), note(1, 2, 'f'), note(2, 3, 'e'), note(3, 4, 'g'))
self.assertEqual(generator.solo.scoring.motif_fidelity(unrelated, motif, 'rhythmic_reference'), 0)
self.assertLess(generator.solo.scoring.motif_fidelity(adapted, motif, 'contour_reference'), generator.solo.scoring.motif_fidelity(unrelated, motif, 'contour_reference'))
changed_ending = (*adapted[:3], note(3, 4, 'b'))
self.assertEqual(generator.solo.scoring.motif_fidelity(adapted, motif, 'opening_reference'), generator.solo.scoring.motif_fidelity(changed_ending, motif, 'opening_reference'))
def test_contextual_invention_and_identity_ignore_titles(self):
source = tests.test_solo.song(count=4, chords=('CM7',))
analysis = generator.solo.analysis.analyze(source)
seed = generator.solo.analysis.identity(analysis, source['staves'][0]['measures'], 'electric')
source['metadata']['title'] = 'Unrelated title'
self.assertEqual(seed, generator.solo.analysis.identity(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], 'electric'))
up = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4), choices=choices(), seed=seed)
changed = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4), choices=choices('D', 2), seed=seed, variant=1)
self.assertNotEqual(up.contour(), changed.contour())
def test_continuation_keeps_fret_position_and_one_attack(self):
previous = note(0, 4, position=generator.fretboard.TabPosition(2, 8, 67))
following = note(4, 6, continuation=True)
placed = generator.solo.placement.place((following,), fractions.Fraction(120), previous)
self.assertEqual(placed[0], 0)
self.assertEqual(placed[1][0].position, previous.position)
joined = generator.solo.types.sounding_notes((previous, *placed[1]))
self.assertEqual([(n.start, n.end) for n in joined], [(0, 6)])
def test_same_ending_does_not_merge_different_gestures(self):
passage = plan()
left = (note(0, 2, 'c'), note(2, 4, 'e'), note(4, 8, 'g'))
right = (note(0, 1, 'a'), note(1, 4, 'c'), note(4, 8, 'g'))
self.assertNotEqual(generator.solo.types.ContinuationState(passage.id, left, last=left[-1]), generator.solo.types.ContinuationState(passage.id, right, last=right[-1]))
def test_lookahead_can_choose_a_lower_ranked_statement(self):
first = dataclasses.replace(plan(), end=fractions.Fraction(4), ending='connected')
second = dataclasses.replace(plan(), id='answer', start=fractions.Fraction(4), relationship='answer')
high = generator.solo.types.Phrase(first, (generator.solo.types.Decision(0, 4, 'fixture', 'invented', (note(0, 4, 'g', octave=5),)),), 0)
low = generator.solo.types.Phrase(first, (generator.solo.types.Decision(0, 4, 'fixture', 'invented', (note(0, 4, 'c'),)),), 0.1)
answer = generator.solo.types.Phrase(second, (generator.solo.types.Decision(4, 8, 'fixture', 'answer', (note(4, 8, 'd'),)),), 0)
selected = generator.solo.composer.choose_phrases((first, second), lambda p, previous: [high, low] if p.id == first.id else [answer])
self.assertEqual(selected[0], low)
def test_committed_statement_still_affects_final_answer_selection(self):
first = dataclasses.replace(plan(), end=fractions.Fraction(4), ending='connected')
second = dataclasses.replace(plan(), id='answer', start=fractions.Fraction(4), relationship='answer')
statement = generator.solo.types.Phrase(first, (generator.solo.types.Decision(0, 4, 'fixture', 'invented', (note(0, 4, 'c'),)),), 0)
far = generator.solo.types.Phrase(second, (generator.solo.types.Decision(4, 8, 'fixture', 'answer', (note(4, 8, 'g', octave=5),)),), 0)
near = generator.solo.types.Phrase(second, (generator.solo.types.Decision(4, 8, 'fixture', 'answer', (note(4, 8, 'd'),)),), 0.1)
selected = generator.solo.composer.choose_phrases((first, second), lambda p, previous: [statement] if p.id == first.id else [far, near])
self.assertEqual(selected[-1], near)
-242
View File
@@ -1,242 +0,0 @@
"""Construction contracts, not claims about listener preference."""
import dataclasses
import fractions
import unittest
import generator.rhythm
import generator.solo.drafts
import generator.solo.harmonic
import generator.solo.realization
import generator.solo.scoring
import generator.solo.types
import tests.rhythm_fixtures
import tests.solo_fixtures
import tests.test_solo
import tests.test_solo_development
import tests.test_solo_passages
F = fractions.Fraction
note = tests.test_solo_passages.note
def illustration():
"""User-specified four-bar example; additional fixtures await listening review."""
events = ((0, 1, 'd', 0, 4), (2, 3, 'f', 0, 4), (3, F(7, 2), 'g', 0, 4), (F(7, 2), 4, 'a', 0, 4),
(4, 5, 'b', -1, 4), (5, 6, 'g', 0, 4), (6, F(13, 2), 'a', 0, 4), (F(13, 2), 7, 'g', 0, 4), (7, 8, 'e', 0, 4),
(8, 9, 'f', 0, 4), (9, F(19, 2), 'g', 0, 4), (F(19, 2), 10, 'a', 0, 4), (10, 11, 'c', 0, 5), (11, 13, 'a', 0, 4),
(13, F(27, 2), 'g', 0, 4), (F(27, 2), 14, 'f', 0, 4), (14, 16, 'd', 0, 4))
notes = tuple(dataclasses.replace(note(a, b, pitch, alter, octave), event_id=f'example_{i}') for i, (a, b, pitch, alter, octave) in enumerate(events))
choices = tests.test_solo_passages.choices(('Gm7', 'C7', 'FM7', 'BbM7'))
harmony = generator.solo.harmonic.Harmony(choices)
home = generator.solo.realization.make_identity('home', 'user_example', notes[1:5], F(2), F(5), 'statement', harmony)
groups = []
for identifier, operation, selected in (('home', 'statement', notes[1:5]), ('extension', 'extend', notes[9:13])):
start = selected[0].start
groups.append(generator.solo.types.Transformation(identifier, 'user_example' if identifier == 'home' else 'home', 'home', operation, start, selected[-1].end, tuple(n.event_id for n in selected), tuple((n.start - start, n.end - start) for n in selected), (1, 1, 1)))
anchor = generator.solo.types.Anchor('terminal', F(14), F(14), notes[-1].pitch, frozenset({2}), (55, 76), F(2), 'terminal', 'BbM7', hold_until=F(16))
draft = generator.solo.types.MelodicDraft('illustration', F(), F(16), notes, tuple(groups), (notes[-1].event_id,), (), (anchor,), home, 'statement', (F(5), F(8)))
return draft, choices
class DraftRepresentationTests(unittest.TestCase):
def test_user_phrase_is_valid_and_contains_seventeen_attacks(self):
draft, choices = illustration()
harmony = generator.solo.harmonic.Harmony(choices)
self.assertEqual(len(draft.notes), 17)
self.assertEqual(draft.rests(), ((1, 2),))
self.assertTrue(harmony.evaluate(draft.notes, complete=True).valid)
self.assertTrue(generator.solo.drafts.contract_valid(draft, draft.notes)[0])
self.assertTrue(generator.solo.harmonic.anchors_valid(draft.anchors, draft.notes, 16, complete=True, closure='closed')[0])
self.assertTrue(any(t.start < 4 < t.end for t in draft.transformations))
def test_partition_preserves_draft_relationships_and_harmonic_roles(self):
draft, choices = illustration()
whole = draft.notes
split = []
for n in whole:
points = sorted({n.start, n.end} | {F(t) for t in range(1, 16) if n.start < t < n.end})
split.extend(dataclasses.replace(n, start=a, end=b, continuation=i > 0) for i, (a, b) in enumerate(zip(points, points[1:])))
self.assertEqual(generator.solo.types.sounding_notes(split), whole)
self.assertEqual(generator.solo.drafts.relationship_features(draft, whole), generator.solo.drafts.relationship_features(draft, split))
self.assertTrue(generator.solo.drafts.contract_valid(draft, split)[0])
harmony = generator.solo.harmonic.Harmony(choices)
self.assertEqual(harmony.describe(whole), harmony.describe(split))
def test_disconnected_alternative_breaks_declared_rise_at_same_density(self):
draft, _ = illustration()
changed = list(draft.notes)
changed[2] = dataclasses.replace(changed[2], pitch=note(0, 1, 'e').pitch)
self.assertEqual(len(changed), len(draft.notes))
self.assertEqual(sum(n.end - n.start for n in changed), sum(n.end - n.start for n in draft.notes))
valid, reason = generator.solo.drafts.contract_valid(draft, changed)
self.assertFalse(valid)
self.assertIn('contour', reason)
self.assertGreater(generator.solo.drafts.relationship_features(draft, changed)['direction'], 0)
def test_catalog_keeps_cross_bar_sustain_as_one_event(self):
event = generator.rhythm.Event
bar = generator.rhythm.Bar
snippet = generator.rhythm.Snippet('ties', '4/4', (bar((event(F(), F(3), False, 'half'), event(F(3), F(1), True, 'quarter')), F(4)), bar((event(F(), F(1), True, 'quarter', continuation=True), event(F(1), F(3), True, 'half')), F(4))))
catalog = generator.solo.drafts.catalog(generator.rhythm.Library((snippet,)), '4/4')
self.assertEqual(catalog[0][1], ((3, 5), (5, 8)))
def test_extension_reopens_only_an_unprotected_provisional_tail(self):
slot = generator.solo.drafts.Slot('tail', F(2), F(4), 0, 2)
result, cursor = generator.solo.drafts.reopen((slot,), F(4))
self.assertEqual((result[-1].end, cursor), (F(7, 2), F(7, 2)))
for protected in (dataclasses.replace(slot, protected=True), dataclasses.replace(slot, forced=note(0, 1, 'e').pitch)):
self.assertEqual(generator.solo.drafts.reopen((protected,), F(4)), ((protected,), F(4)))
def test_existing_terminal_contract_is_not_retimed(self):
draft, choices = illustration()
plan = dataclasses.replace(tests.test_solo_development.plan(), end=F(16), target=generator.solo.types.TargetEvent(F(14), F(14), frozenset({2}), 'BbM7', 'explicit ending'))
self.assertEqual(generator.solo.drafts.ending_options(draft.anchors, plan, choices), (draft.anchors,))
def test_ending_alternatives_vary_timing_without_changing_destination(self):
draft, choices = illustration()
plan = dataclasses.replace(tests.test_solo_development.plan(), end=F(16))
endings = generator.solo.drafts.ending_options(draft.anchors, plan, choices)
self.assertEqual({a[-1].duration for a in endings}, {F(1, 2), F(1), F(2), F(3)})
self.assertTrue(all(a[-1].pitch == draft.anchors[-1].pitch and a[-1].earliest + a[-1].duration == a[-1].hold_until for a in endings))
self.assertEqual({16 - a[-1].hold_until for a in endings}, {F(), F(1, 2), F(1)})
def test_revision_cannot_change_terminal_hold_or_identifying_rhythm(self):
draft, _ = illustration()
for index in (2, len(draft.notes) - 1):
notes = list(draft.notes)
notes[index] = dataclasses.replace(notes[index], end=notes[index].end - F(1, 4))
self.assertFalse(generator.solo.drafts.contract_valid(draft, notes)[0])
def test_unrelated_single_note_is_not_perfect_development(self):
draft, _ = illustration()
event = draft.notes[0]
transform = generator.solo.types.Transformation('short', 'home', 'home', 'extend', event.start, event.end, (event.event_id,), ((F(), F(1)),))
candidate = dataclasses.replace(draft, notes=(event,), transformations=(transform,), structural=(), anchors=(), joins=())
self.assertEqual(generator.solo.drafts.relationship_features(candidate, candidate.notes)['development'], 1)
def test_diminution_and_broadening_keep_exact_timing_and_event_order(self):
pattern = ((F(), F(1)), (F(3, 2), F(2)), (F(2), F(5, 2)))
transformed, length = generator.solo.drafts.develop_timing(pattern, F(3), F(1, 2), F(3, 4), True)
self.assertEqual(len(transformed), len(pattern))
self.assertTrue(all(b - a >= F(1, 2) for a, b in transformed))
self.assertTrue(all(b <= c for (_, b), (c, _) in zip(transformed, transformed[1:])))
self.assertLess(length, 3)
def test_occupancy_goal_cannot_create_arbitrary_tuplet_durations(self):
pattern = ((F(), F(1, 2)), (F(1), F(3, 2)), (F(2), F(5, 2)))
for occupancy in (F(3, 4), F(9, 10)):
for unit in (F(1, 2), F(1), F(2)):
transformed, length = generator.solo.drafts.develop_timing(pattern, F(3), unit, occupancy, True)
self.assertTrue(all((a * 4).denominator == (b * 4).denominator == 1 for a, b in transformed))
self.assertEqual((length * 4).denominator, 1)
def test_pending_treatment_constrains_the_new_draft_pitch_search(self):
choices = tests.test_solo_passages.choices(('Gm7', 'C7'))
harmony = generator.solo.harmonic.Harmony(choices)
held = note(2, F(9, 2), 'f')
treatment = generator.solo.types.HarmonicTreatment('pending', 'suspension', 65, F(4), F(9, 2), 64, F(5))
incoming = generator.solo.types.ContinuationState('p', (held,), treatments=(treatment,), last=held)
plan = dataclasses.replace(tests.test_solo_development.plan(), start=F(9, 2), end=F(6))
slot = generator.solo.drafts.Slot('resolve', F(9, 2), F(5), 0, 0)
group = generator.solo.drafts.Group('g', 'continue', F(9, 2), F(6), 'prepared treatment')
rhythm = generator.solo.drafts.RhythmDraft('r', (slot,), (group,), (), plan.motif, 'continue')
paths, error = generator.solo.realization.draft_pitch_paths(rhythm, plan, choices, harmony, tests.solo_fixtures.library(), incoming, generator.solo.realization.SearchContext())
self.assertTrue(paths, error)
self.assertTrue(all(notes[0].pitch.number() == 64 for notes, _, _ in paths))
self.assertTrue(all(harmony.evaluate((held, *notes), contracts, complete=True).valid for notes, _, contracts in paths))
def test_join_search_can_rewrite_both_sides_and_preserve_outside_events(self):
pitches = (('c', 4), ('g', 4), ('e', 4), ('c', 5), ('c', 4), ('f', 4), ('g', 4), ('e', 4))
notes = tuple(dataclasses.replace(note(i, i + 1, letter, octave=octave), event_id=f'n{i}') for i, (letter, octave) in enumerate(pitches))
plan = tests.test_solo_development.plan()
harmony = generator.solo.harmonic.Harmony(tests.test_solo_development.choices())
transform = generator.solo.types.Transformation('connecting_run', '', '', 'close', F(), F(8), tuple(n.event_id for n in notes), tuple((n.start, n.end) for n in notes), preserves=())
draft = generator.solo.types.MelodicDraft('join_fixture', F(), F(8), notes, (transform,), ('n0', 'n7'), (), (), None, 'continue', (F(4),))
incoming = generator.solo.types.ContinuationState('')
original = generator.solo.realization.checked_phrase(plan, draft, notes, (), incoming, harmony, F(120))
self.assertIsNotNone(original)
revised = generator.solo.realization.improve_window(original, incoming, harmony, tests.solo_fixtures.library(), F(120), F(2), F(6), 'join', generator.solo.realization.SearchContext())
actual = generator.solo.realization.emitted_notes(revised)
self.assertLess(revised.score, original.score)
self.assertTrue(any(a.pitch != b.pitch and a.start < 4 for a, b in zip(notes, actual)))
self.assertTrue(any(a.pitch != b.pitch and a.start >= 4 for a, b in zip(notes, actual)))
self.assertEqual((actual[0].pitch, actual[-1].pitch), (notes[0].pitch, notes[-1].pitch))
self.assertTrue(harmony.evaluate(actual, complete=True).valid)
def test_passage_boundary_does_not_count_a_held_note_as_a_new_attack(self):
choices = tests.test_solo_development.choices()
harmony = generator.solo.harmonic.Harmony(choices)
placement = generator.solo.placement.place((note(0, 4, 'g'),), F(120))[1][0]
incoming = generator.solo.types.ContinuationState('old', (placement,), last=placement)
held = dataclasses.replace(note(4, 8, 'g'), continuation=True, event_id='hold')
plan = dataclasses.replace(tests.test_solo_development.plan(), start=F(4), passage_id='new', passages=(generator.solo.types.ActivitySpan(F(4), F(8), 'active', F(1), F(3, 4)),))
transform = generator.solo.types.Transformation('hold', '', '', 'close', F(4), F(8), ('hold',), ((F(), F(4)),), preserves=())
draft = generator.solo.types.MelodicDraft('hold', F(4), F(8), (held,), (transform,), (), (), (), None, 'continue')
phrase = generator.solo.realization.checked_phrase(plan, draft, (held,), (), incoming, harmony, F(120))
self.assertIsNotNone(phrase)
features = generator.solo.scoring.features(plan, (dataclasses.replace(placement, end=F(8)),), choices)
self.assertEqual(phrase.score, generator.solo.scoring.value(features))
def test_new_passage_carries_pending_but_not_completed_suspensions(self):
treatment = generator.solo.types.HarmonicTreatment('prepared', 'suspension', 65, F(4), F(9, 2), 64, F(5))
held, resolution = note(2, F(9, 2), 'f'), note(F(9, 2), 5, 'e')
plan = dataclasses.replace(tests.test_solo_development.plan(), start=F(5), passage_id='next')
incoming = generator.solo.types.ContinuationState('old', (held,), treatments=(treatment,), last=held)
self.assertEqual(generator.solo.realization.inherited_treatments(plan, incoming), (treatment,))
resolved = dataclasses.replace(incoming, notes=(held, resolution), last=resolution)
self.assertEqual(generator.solo.realization.inherited_treatments(plan, resolved), ())
self.assertEqual(generator.solo.realization.inherited_treatments(dataclasses.replace(plan, passage_id='old'), resolved), (treatment,))
class DraftCompositionTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.solo = tests.test_solo.compose(tests.test_solo.song(count=8, chords=('Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7')))
def test_production_candidates_contain_complete_playable_drafts(self):
harmony = generator.solo.harmonic.Harmony(self.solo.choices)
previous = None
for phrase in self.solo.phrases:
draft = phrase.draft
self.assertIsNotNone(draft)
self.assertEqual((draft.start, draft.end), (phrase.plan.start, phrase.plan.end))
prefix = (dataclasses.replace(previous, position=None),) if previous else ()
self.assertTrue(harmony.evaluate((*prefix, *draft.notes), phrase.state.treatments, until=draft.end, enclosures=draft.enclosures, expressions=draft.expressions).valid)
self.assertTrue(generator.solo.placement.place(draft.notes, self.solo.tempo, previous))
self.assertTrue(draft.transformations)
previous = phrase.state.last
def test_source_family_survives_phrase_variations(self):
self.assertEqual(len({p.state.home.id for p in self.solo.phrases}),1)
for phrase in self.solo.phrases:
self.assertTrue(all(t.home == phrase.state.home.id for t in phrase.draft.transformations))
self.assertTrue(phrase.state.home.chord_degrees)
self.assertTrue(phrase.draft.source_links)
def test_groups_cross_barlines_without_reference_timing_reset(self):
# Exercise an explicitly spanning construction; do not require a changed
# search objective to pick the same old winner merely to test timing.
plan = self.solo.phrases[0].plan
pattern = ((F(), F(1)), (F(1), F(3, 2)), (F(3, 2), F(2)), (F(2), F(3)))
constructed = generator.solo.drafts.construct(plan, plan.motif, 'statement', ('spanning', pattern, F(3)), (), 0, self.solo.choices)
self.assertIsNotNone(constructed)
self.assertTrue(any(g.start < 4 < g.end for g in constructed.groups))
self.assertTrue(any(s.start == F(4) and s.end == F(9, 2) for s in constructed.slots))
for phrase in self.solo.phrases:
by_id = {n.event_id: n for n in phrase.draft.notes}
for transform in phrase.draft.transformations:
self.assertEqual(tuple((by_id[e].start - transform.start, by_id[e].end - transform.start) for e in transform.events), transform.rhythm)
def test_new_passage_candidates_explicitly_recall_or_contrast(self):
prior = self.solo.phrases[0].state
plan = dataclasses.replace(self.solo.phrases[0].plan, id='new', passage_id='new_passage')
rhythms = generator.solo.drafts.rhythms(plan, prior, tests.test_solo.song(count=8)['staves'][0]['measures'], self.solo.choices, tests.rhythm_fixtures.library())
self.assertTrue(any(r.reference.id == prior.home.id and r.relationship in ('continue', 'answer', 'recall') for r in rhythms))
self.assertTrue(all(r.relationship != 'statement' for r in rhythms))
def test_cell_boundaries_are_not_phrase_rhythm_inputs(self):
prior = generator.solo.types.ContinuationState('')
plan = self.solo.phrases[0].plan
measures = tests.test_solo.song(count=8)['staves'][0]['measures']
first = generator.solo.drafts.rhythms(plan, prior, measures, self.solo.choices, tests.rhythm_fixtures.library())
second = generator.solo.drafts.rhythms(dataclasses.replace(plan, gestures=(generator.solo.types.Gesture('arbitrary_cell', F(), F(3), 'continue', 'contour_reference'),)), prior, measures, self.solo.choices, tests.rhythm_fixtures.library())
self.assertEqual(first, second)
def test_full_realization_revalidates_each_accepted_revision(self):
generator.solo.composer.validate(self.solo)
for phrase in self.solo.phrases:
notes = generator.solo.realization.emitted_notes(phrase)
self.assertTrue(generator.solo.drafts.contract_valid(phrase.draft, notes)[0])
self.assertTrue(all(1 <= n.position.fret <= 12 for n in notes))
for edit in phrase.edits:
self.assertLessEqual(edit.end - edit.start, 8 if edit.kind in ('replace_idea', 'complete_route') else 4)
def test_join_comparison_reuses_identical_upstream_draft_pool(self):
plan = self.solo.phrases[0].plan
measures = tests.test_solo.song(count=8)['staves'][0]['measures']
context = generator.solo.realization.SearchContext(revise=False)
arguments = (plan, measures, self.solo.choices, tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), self.solo.tempo)
generator.solo.realization.alternatives(*arguments, context=context)
fingerprints = dict(context.diagnostics['draft_pool_fingerprints'])
constructed = context.diagnostics['generated_candidates']
context.revise = True
generator.solo.realization.alternatives(*arguments, context=context)
self.assertEqual(context.diagnostics['generated_candidates'], constructed)
self.assertEqual(context.diagnostics['draft_pool_fingerprints'], fingerprints)
self.assertEqual(context.diagnostics['reused_draft_pools'], 1)
-258
View File
@@ -1,258 +0,0 @@
import dataclasses
from fractions import Fraction as F
import unittest
from generator.solo import types, harmonic, enclosures, phrasing, composer, placement, notation, report
import generator.harmony
import generator.model
import tests.scale_fixtures
import tests.solo_fixtures
from tests.test_solo_passages import choices
from tests.test_solo import song
def note(start,end,letter,alter=0,octave=4,identity=''):
return types.Note(F(start),F(end),types.Pitch(letter,F(alter),octave),event_id=identity or f'{start}:{letter}')
def fixture(symbol='C7',target=None,scale=None):
target=target or note(4,8,'e',identity='target')
harmony=harmonic.Harmony(choices((symbol,symbol),scale=scale))
pair=enclosures.approach(target,harmony)
contract=types.EnclosureContract('enclosure',*pair,target,harmony.at(target.start).chord,'terminal',types.SustainedTail(target.start,target.end,target.pitch.number()))
return harmony,(*pair,target),contract
class EnclosureContracts(unittest.TestCase):
def test_existing_upper_can_continue_without_a_new_attack(self):
h,notes,c=fixture('Dm',note(4,8,'d'))
upper=dataclasses.replace(notes[0],start=F(5,2))
contract=dataclasses.replace(c,upper=upper,approach_start=F(3))
self.assertTrue(h.evaluate((upper,*notes[1:]),enclosures=(contract,)).valid)
# Joining a tie must not hide an exposed long non-chord upper neighbor.
long_upper=dataclasses.replace(upper,start=F(1))
invalid=dataclasses.replace(contract,upper=long_upper)
self.assertFalse(h.evaluate((long_upper,*notes[1:]),enclosures=(invalid,)).valid)
split=(dataclasses.replace(upper,end=F(3)),dataclasses.replace(upper,start=F(3),continuation=True,event_id='tie'),*notes[1:])
self.assertEqual(h.evaluate(split,enclosures=(contract,)),h.evaluate((upper,*notes[1:]),enclosures=(contract,)))
def test_worked_examples_and_actual_collection_membership(self):
hm=tests.scale_fixtures.library().scale('harmonic_minor',1,'D')
for symbol,target,scale,expected in (
('C7',note(4,8,'e'),None,('F4','D#4','E4')),
('FM7',note(4,8,'a',octave=3),None,('Bb3','G#3','A3')),
('A7',note(4,8,'c',1),hm,('D4','C4','C#4')),
('Dm',note(4,8,'d'),None,('E4','C#4','D4'))):
with self.subTest(symbol=symbol):
harmony,notes,contract=fixture(symbol,target,scale)
self.assertEqual(tuple(n.pitch.label() for n in notes),expected)
self.assertFalse(harmony.evaluate(notes).valid)
self.assertTrue(harmony.evaluate(notes,enclosures=(contract,),complete=True).valid)
self.assertEqual(harmony.evaluate(notes,enclosures=(contract,)).exposure,0)
self.assertEqual(sum(r['scale_exception'] for r in harmony.describe(notes,enclosures=(contract,))),1)
def test_in_map_enclosure_does_not_need_exception(self):
h,notes,c=fixture('FM7',note(4,8,'f'))
self.assertEqual([n.pitch.label() for n in notes],['G4','E4','F4'])
self.assertTrue(h.evaluate(notes).valid)
self.assertTrue(h.evaluate(notes,enclosures=(c,)).valid)
self.assertFalse(any(r['scale_exception'] for r in h.describe(notes,enclosures=(c,))))
def test_missing_changed_target_wrong_octave_and_repeated_attack_fail(self):
h,notes,c=fixture()
for actual in (notes[:-1],(*notes[:-1],dataclasses.replace(notes[-1],pitch=types.Pitch('e',F(),3))),
(*notes[:-1],dataclasses.replace(notes[-1],start=F(9,2))),
(*notes[:-1],dataclasses.replace(notes[-1],end=F(6)),dataclasses.replace(notes[-1],start=F(6),event_id='reattack'))):
self.assertFalse(h.evaluate(actual,enclosures=(c,)).valid)
self.assertFalse(h.evaluate(notes,enclosures=(c,),until=F(7),complete=True).valid)
def test_roles_alone_never_authorize_chromatic_pitches(self):
h,notes,c=fixture()
for role in ('enclosure_lower','chromatic_approach','quote'):
actual=tuple(dataclasses.replace(n,role=role) for n in notes)
self.assertFalse(h.evaluate(actual).valid)
def test_complete_sounding_target_is_partition_invariant(self):
h,notes,c=fixture()
split=(*notes[:-1],dataclasses.replace(notes[-1],end=F(6)),dataclasses.replace(notes[-1],start=F(6),continuation=True,event_id='tie'))
self.assertEqual(h.evaluate(split,enclosures=(c,)),h.evaluate(notes,enclosures=(c,)))
self.assertEqual(h.describe(split,enclosures=(c,)),h.describe(notes,enclosures=(c,)))
self.assertEqual(types.sounding_notes(split),notes)
def test_orphan_long_approach_order_and_duplicate_contracts_fail(self):
h,notes,c=fixture()
variants=(dataclasses.replace(c,upper=dataclasses.replace(c.upper,start=F(2))),
dataclasses.replace(c,lower=dataclasses.replace(c.lower,pitch=types.Pitch('f',F(1),4))),
dataclasses.replace(c,target=dataclasses.replace(c.target,event_id='missing')),
dataclasses.replace(c,chord='Dm'))
for changed in variants:
actual=(changed.upper,changed.lower,changed.target)
if changed.target.event_id=='missing':actual=notes
self.assertFalse(h.evaluate(actual,enclosures=(changed,)).valid)
self.assertFalse(h.evaluate(notes,enclosures=(c,c)).valid)
def test_boundary_crossing_uses_arrival_chord_and_each_actual_scale(self):
hm=tests.scale_fixtures.library().scale('harmonic_minor',1,'D')
cs=choices(('A7','Dm'))
cs=(dataclasses.replace(cs[0],scale=hm),cs[1])
h=harmonic.Harmony(cs);target=note(4,8,'d')
upper,lower=enclosures.approach(target,h)
c=types.EnclosureContract('boundary',upper,lower,target,'Dm','terminal',types.SustainedTail(F(4),F(8),62))
self.assertTrue(h.evaluate((upper,lower,target),enclosures=(c,)).valid)
self.assertTrue(h.evaluate((upper,lower,target)).valid) # C# still belongs to A7's map
self.assertFalse(h.evaluate((upper,lower,target),enclosures=(dataclasses.replace(c,chord='A7'),)).valid)
missing=harmonic.Harmony((dataclasses.replace(cs[0],scale=None),cs[1]))
self.assertFalse(missing.evaluate((upper,lower,target),enclosures=(c,)).valid)
def test_held_target_must_remain_chordal_after_boundary(self):
h,notes,c=fixture('Gm7',note(2,6,'f'))
h=harmonic.Harmony(choices(('Gm7','C7')))
self.assertFalse(h.evaluate(notes,enclosures=(c,)).valid)
def test_transposition_preserves_contract_validation(self):
h,notes,c=fixture()
for octave in (-1,0,1):
moved=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=n.pitch.octave+octave)) for n in notes)
contract=dataclasses.replace(c,upper=moved[0],lower=moved[1],target=moved[2],protected_tail=dataclasses.replace(c.protected_tail,pitch=moved[2].pitch.number()))
self.assertTrue(h.evaluate(moved,enclosures=(contract,)).valid)
def test_spelling_and_notation_ties_tab_and_playback_survive(self):
import generator.playback
import generator.musicxml_writer
h,notes,c=fixture(target=note(3,7,'e',identity='target'))
placed=placement.place(notes,F(120))[1]
doc=song(count=2,chords=('C7',))
measures=notation.measures(doc['staves'][0]['measures'],placed,'enclosure_test')
notation.replace_region(doc,0,measures)
generator.model.validate_document(doc)
import generator.fretboard
generator.fretboard.validate_document(doc)
pitches=[p for m in measures for v in m['voices'] for n in v['notes'] for p in n['pitches']]
self.assertTrue(any(p.get('tie_from') for p in pitches))
self.assertTrue(any(p['letter']=='d' and generator.model.fraction(p['alter'])==1 for p in pitches))
self.assertTrue(all(1<=p['tablature']['fret']<=12 for p in pitches))
# The guitar's written octave must be removed exactly once at playback.
doc['staves'][0]['measures'][0]['transpose']={'diatonic':-7,'chromatic':-12,'octave_change':0}
doc['staves'][0]['measures'][1]['transpose']={'diatonic':-7,'chromatic':-12,'octave_change':0}
timeline=generator.playback.compile_timeline(doc)
self.assertEqual([generator.model.fraction(e['pitch']) for e in timeline['events']],[65,63,64])
self.assertEqual([generator.model.fraction(e['duration']) for e in timeline['events']],[F(1,2),F(1,2),F(4)])
tree=generator.musicxml_writer.musicxml_tree(doc)
self.assertTrue(any(e.text=='1' for e in tree.findall('.//pitch/alter')))
class DestinationPriorities(unittest.TestCase):
def test_actual_symbols_define_members_without_slash_or_omitted_degrees(self):
cases={'C7':{4,10},'C':{4},'Cm':{3},'C6':{4,9},'Csus4':{5},'Csus2':{2},'C7b5':{4,6,10},'C7no3':{10},'C/D':{4},'C9':{4,10}}
for symbol,expected in cases.items():
with self.subTest(symbol=symbol):self.assertEqual(phrasing.defining_members(symbol),expected)
def test_guide_tones_compete_before_nearest_register_cutoff(self):
from generator.solo.themes import pitches
h=harmonic.Harmony(choices(('C7','FM7')))
notes=pitches(h,note(2,4,'c'),60,members=True,limit=4)
self.assertEqual({p.number()%12 for p in notes},{0,4,7,10})
cost=lambda p:phrasing.destination_cost(p,F(2),F(4),h,goal=60)
self.assertLess(cost(types.Pitch('e',F(),4)),cost(types.Pitch('c',F(),4)))
self.assertLess(cost(types.Pitch('b',F(-1),3)),cost(types.Pitch('g',F(),3)))
def solo_fixture():
h=harmonic.Harmony(choices(('C7','C7')))
notes=(note(0,1,'g'),note(1,2,'e'),note(2,3,'g'),note(3,4,'f'),note(4,8,'e',identity='target'))
notes=placement.place(notes,F(120))[1]
anchor=types.Anchor('close',F(4),F(4),notes[-1].pitch,frozenset({4}),(41,76),F(4),'terminal','C7',hold_until=F(8))
motif=types.Motif('theme',notes[:3],F(3),())
plan=types.PhrasePlan('test',F(),F(8),motif,'develop','thematic_recomposition',None,(41,76),F(1),F(),ending='sustain',closure='closed')
transform=types.Transformation('develop','','theme','develop',F(),F(8),tuple(n.event_id for n in notes),tuple((n.start,n.end) for n in notes),preserves=())
draft=types.MelodicDraft('draft',F(),F(8),notes,(transform,),(),(),(anchor,),None,'develop',sustained_tails=(types.SustainedTail(F(4),F(8),64),))
decision=types.Decision(F(),F(8),'fixture','develop',notes)
state=types.ContinuationState('test',notes=notes,last=notes[-1])
phrase=types.Phrase(plan,(decision,),state=state,anchors=(anchor,),draft=draft)
return types.Solo('acoustic',h.choices,(phrase,),F(120),F(8)),h
class EnclosureRefinement(unittest.TestCase):
def test_atomic_candidates_preserve_target_timing_and_protections(self):
solo,h=solo_fixture();phrase=solo.phrases[0]
options=enclosures.candidates(phrase,h,tests.solo_fixtures.library(),solo.tempo,[])
terminal=[p for _,p in options if p.draft.enclosures[0].target_role=='terminal']
self.assertTrue(terminal)
for p in terminal:
self.assertEqual(p.draft.enclosures[0].target,phrase.draft.notes[-1])
self.assertTrue(h.evaluate(p.draft.notes,enclosures=p.draft.enclosures).valid)
actual=placement.place(p.draft.notes,solo.tempo)[1]
placed=dataclasses.replace(p,decisions=(dataclasses.replace(p.decisions[0],notes=actual),),state=dataclasses.replace(p.state,notes=actual))
composer.validate(dataclasses.replace(solo,phrases=(placed,)))
protected=dataclasses.replace(phrase,draft=dataclasses.replace(phrase.draft,protected=tuple(n.event_id for n in phrase.draft.notes)))
self.assertFalse(enclosures.candidates(protected,h,tests.solo_fixtures.library(),solo.tempo,[]))
def test_intermediate_enclosure_leaves_continuation_and_terminal_hold_intact(self):
solo,h=solo_fixture();phrase=solo.phrases[0]
options=enclosures.candidates(phrase,h,tests.solo_fixtures.library(),solo.tempo,[])
intermediate=[p for _,p in options if p.draft.enclosures[0].target_role=='intermediate']
self.assertTrue(intermediate)
for candidate in intermediate:
target=candidate.draft.enclosures[0].target
self.assertTrue(any(n.start>target.start for n in candidate.draft.notes))
self.assertEqual(candidate.draft.notes[-1],phrase.draft.notes[-1])
self.assertEqual(candidate.state.home,phrase.state.home)
def test_short_score_can_choose_direct_arrivals_without_a_quota(self):
solo,h=solo_fixture();phrase=solo.phrases[0]
protected=dataclasses.replace(phrase,draft=dataclasses.replace(phrase.draft,protected=tuple(n.event_id for n in phrase.draft.notes)))
result=enclosures.refine(dataclasses.replace(solo,phrases=(protected,)),tests.solo_fixtures.library())
self.assertEqual(result.diagnostics['enclosures_selected'],0)
self.assertEqual([(n.pitch,n.start,n.end) for n in result.notes()],[(n.pitch,n.start,n.end) for n in solo.notes()])
self.assertTrue(result.diagnostics['enclosure_rejections'])
def test_already_sounding_entrance_is_not_detached_for_ornamentation(self):
solo,h=solo_fixture();p=solo.phrases[0]
held=dataclasses.replace(p.draft.notes[0],continuation=True)
p=dataclasses.replace(p,draft=dataclasses.replace(p.draft,notes=(held,*p.draft.notes[1:])))
self.assertFalse(any(n.event_id==held.event_id for n,_ in enclosures.declared_targets(p,h)))
rejected=[]
self.assertFalse(enclosures.candidates(p,h,tests.solo_fixtures.library(),solo.tempo,rejected))
self.assertIn('sustain retained',rejected[0]['reason'])
self.assertTrue(p.draft.notes[0].continuation)
def test_no_gap_filling_and_no_unplayable_enclosure(self):
solo,h=solo_fixture();p=solo.phrases[0]
draft=dataclasses.replace(p.draft,notes=tuple(n for n in p.draft.notes if n.start!=3))
candidates=enclosures.candidates(dataclasses.replace(p,draft=draft),h,tests.solo_fixtures.library(),solo.tempo,[])
self.assertFalse(any(c.draft.enclosures[0].target.event_id=='target' for _,c in candidates))
self.assertIsNone(enclosures.approach(note(4,8,'e',octave=5),h))
def test_disabled_is_exact_strict_input_and_selection_is_deterministic(self):
solo,h=solo_fixture();library=tests.solo_fixtures.library()
disabled=enclosures.refine(solo,library,False)
self.assertEqual(disabled.notes(),solo.notes())
first=enclosures.refine(solo,library);second=enclosures.refine(solo,library)
self.assertEqual(first.notes(),second.notes())
self.assertEqual(enclosures.strict(first).notes(),solo.notes())
composer.validate(first);composer.validate(disabled)
self.assertTrue(report.structure(first)['harmonic_valid'])
class EnclosureCompositionAPI(unittest.TestCase):
def test_disable_joint_expressions_selects_a_valid_strict_alternative(self):
from generator.solo import analysis
import tests.rhythm_fixtures
doc=song(count=8,chords=('Gm7','C7','FM7','BbM7','Em7b5','A7','Dm7','Dm7'))
a=analysis.analyze(doc)
args=(a,doc['staves'][0]['measures'],'acoustic',F(32))
kwargs=dict(scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library())
decorated=composer.compose(*args,**kwargs)
strict=composer.compose(*args,**kwargs,enclosures_enabled=False)
# A jointly composed color/anticipation can change phrase selection. Its
# disabled alternative need not be the selected decorated phrase minus
# notes; both versions must retain the same form and valid sounding spans.
self.assertEqual(decorated.end,strict.end)
composer.validate(decorated)
composer.validate(strict)
self.assertTrue(harmonic.Harmony(strict.choices).evaluate(strict.notes()).valid)
self.assertTrue(all(not p.draft.enclosures for p in strict.phrases))
self.assertGreater(sum(v['proposed'] for v in decorated.diagnostics['expression_operations'].values()),0)
self.assertTrue(all(not p.draft.expressions for p in strict.phrases))
if __name__=='__main__':unittest.main()
-147
View File
@@ -1,147 +0,0 @@
"""Production source variations, strict scale permission and paired interpretation."""
import copy
import dataclasses
from fractions import Fraction as F
import pathlib
import unittest
from generator.solo import analysis, composer, drafts, harmonic, leads, placement, realization, scoring, types
import generator.fretboard
import generator.model
import tests.test_solo as fixture
import tests.test_solo_development as notes_fixture
import tests.test_solo_passages as passage_fixture
import tests.scale_fixtures
import tests.rhythm_fixtures
import tests.solo_fixtures
class SourceVariationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.document=fixture.song(count=8,chords=('CM7',))
cls.context=realization.SearchContext()
cls.solo=cls.compose(cls.document,cls.context)
@classmethod
def compose(cls,document,context=None,end=32,part='acoustic'):
return composer.compose(analysis.analyze(document),document['staves'][0]['measures'],part,F(end),
scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),context=context,mode='paraphrase')
def test_production_is_traceable_to_complete_source_phrases(self):
self.assertTrue(self.solo.phrases)
for p in self.solo.phrases:
self.assertIsNotNone(p.draft.source_phrase)
self.assertTrue(p.draft.source_links)
self.assertTrue(p.edits)
self.assertGreater(len(p.draft.source_phrase.notes),8)
self.assertTrue(drafts.contract_valid(p.draft,composer.phrase_notes(p))[0])
self.assertTrue(all(n.sources for n in p.draft.notes))
def test_same_input_and_cache_preserve_complete_music(self):
other=self.compose(self.document,self.context)
self.assertEqual(self.solo.notes(),other.notes())
self.assertGreater(self.context.diagnostics['cache_hits'],0)
def test_source_change_drives_music_with_fixed_harmony(self):
changed=copy.deepcopy(self.document)
for m in changed['staves'][0]['measures']:
for n in m['voices'][0]['notes']:
n['pitches'][0]['letter']='g' if n['pitches'][0]['letter']=='c' else n['pitches'][0]['letter']
other=self.compose(changed)
self.assertNotEqual(leads.signature(self.solo.notes()),leads.signature(other.notes()))
self.assertNotEqual(self.solo.phrases[0].initial.notes[0].pitch.number()%12,other.phrases[0].initial.notes[0].pitch.number()%12)
def test_source_ties_and_rests_are_retained_before_variation(self):
melody=(notes_fixture.note(0,5,'c'),notes_fixture.note(6,9,'e'),notes_fixture.note(9,12,'g'),notes_fixture.note(12,16,'c'))
melody=tuple(dataclasses.replace(n,sources=(f's{i}',)) for i,n in enumerate(melody))
source=analysis.source_phrases(melody,F(),F(16),F(4))
self.assertEqual(tuple(n for p in source for n in p.notes)[0].end,5)
self.assertTrue(any((F(5),F(6)) in p.rests for p in source))
def test_strict_map_rejects_chromatic_labels(self):
harmony=harmonic.Harmony(passage_fixture.choices(('C7',)))
approach=dataclasses.replace(notes_fixture.note(F(5,2),3,'f',1),event_id='approach')
target=dataclasses.replace(notes_fixture.note(3,4,'g'),event_id='target')
contract=types.HarmonicTreatment('removed','chromatic_approach',66,F(5,2),F(3),67,F(3),F(),'approach','target')
self.assertFalse(harmony.evaluate((approach,target),complete=True).valid)
self.assertFalse(harmony.evaluate((approach,target),(contract,),complete=True).valid)
def test_mapped_accidentals_and_half_steps_remain_valid(self):
scale=tests.scale_fixtures.library().scale('harmonic_minor',1,'D')
choices=passage_fixture.choices(('A7','Dm7'),duration=2,scale=scale)
harmony=harmonic.Harmony(choices)
notes=(notes_fixture.note(0,F(3,2),'e'),notes_fixture.note(F(3,2),2,'c',1),notes_fixture.note(2,4,'d'))
self.assertTrue(harmony.evaluate(notes,complete=True).valid)
def test_partition_does_not_change_sounding_evaluation(self):
phrase=self.solo.phrases[0]
whole=phrase.draft.notes;split=[]
for n in whole:
middle=(n.start+n.end)/2
split.extend((dataclasses.replace(n,end=middle),dataclasses.replace(n,start=middle,continuation=True)))
harmony=harmonic.Harmony(self.solo.choices)
self.assertEqual(harmony.evaluate(whole,complete=True),harmony.evaluate(split,complete=True))
self.assertEqual(scoring.features(phrase.plan,whole,self.solo.choices),scoring.features(phrase.plan,split,self.solo.choices))
self.assertTrue(drafts.contract_valid(phrase.draft,split)[0])
def test_low_register_and_tied_placement(self):
low=dataclasses.replace(notes_fixture.note(0,2,'a'),pitch=types.Pitch('a',F(),2))
following=dataclasses.replace(low,start=2,end=4,continuation=True)
placed=placement.place((low,following),F(120))
self.assertEqual(placed[1][0].position.string_number,6)
self.assertEqual(placed[1][0].position,placed[1][1].position)
self.assertEqual({p.string_number for number in range(41,77) for p in placement.positions(number)},set(range(1,7)))
def test_pair_metric_detects_octave_copy(self):
shifted=dataclasses.replace(self.solo,phrases=tuple(dataclasses.replace(p,decisions=tuple(dataclasses.replace(d,notes=leads.shift(d.notes,1)) for d in p.decisions)) for p in self.solo.phrases))
self.assertEqual(leads.similarity(self.solo,shifted),1)
callback=dataclasses.replace(shifted,phrases=tuple(dataclasses.replace(p,plan=dataclasses.replace(p.plan,relationship='callback')) for p in shifted.phrases))
self.assertEqual(leads.similarity(self.solo,callback),0)
def test_later_arrivals_compare_complete_routes_with_different_holds(self):
notes=tuple(dataclasses.replace(notes_fixture.note(a,b,k),event_id=str(a),sources=(str(a),))
for a,b,k in ((0,1,'g'),(1,2,'e'),(2,8,'c')))
source=types.SourcePhrase('ending',F(),F(8),notes,(),'family')
harmony=harmonic.Harmony(passage_fixture.choices(('C7','C7')))
options=leads.elaborate(notes,(),harmony,tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',source)
self.assertEqual({r.destination.end-r.destination.start for _,_,r in options},{F(1,2),F(1),F(3,2),F(2)})
for actual,_,route in options:
self.assertTrue(harmony.evaluate(actual,complete=True).valid)
self.assertEqual(actual[-1],route.destination)
self.assertEqual((actual[0].start,actual[-1].end),(0,8))
def test_repeated_source_cadence_is_not_accidental_monotony(self):
first=self.solo.phrases[0]
changed=dataclasses.replace(first,draft=dataclasses.replace(first.draft,
source_phrase=dataclasses.replace(first.draft.source_phrase,family='unrelated')))
self.assertAlmostEqual(leads.transition(first,changed)-leads.transition(first,first),.15)
def test_space_and_vocal_return_are_preserved(self):
silent=self.compose(fixture.song(count=8,chords=()))
self.assertFalse(silent.notes())
shortened=self.compose(self.document,end=F(29))
self.assertTrue(all(n.end<=29 for n in shortened.notes()))
composer.validate(shortened)
def test_batch_selection_preserves_independent_source_variations(self):
a=analysis.analyze(self.document)
pair=composer.compose_pair(a,{part:(self.document['staves'][0]['measures'],F(32),None) for part in ('acoustic','electric')},
scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),mode='paraphrase')
for solo in pair:
alone=self.compose(self.document,part=solo.part)
self.assertEqual(solo.notes(),alone.notes())
self.assertNotIn('pairs_compared',solo.diagnostics)
for solo in pair:
composer.validate(solo)
for phrase in solo.phrases:
heard={n.event_id:n for n in composer.phrase_notes(phrase)}
for route in phrase.routes:
self.assertEqual(route.notes[-1].pitch,route.destination.pitch)
for note in route.notes:
self.assertEqual((heard[note.event_id].pitch,heard[note.event_id].start,heard[note.event_id].end),(note.pitch,note.start,note.end))
def test_pickup_is_brought_inside_window_without_touching_previous_section(self):
melody=(notes_fixture.note(0,5,'g'),notes_fixture.note(5,6,'a'),notes_fixture.note(6,8,'g'))
melody=tuple(dataclasses.replace(n,sources=(f's{i}',),event_id=f's{i}') for i,n in enumerate(melody))
pickup=tuple(dataclasses.replace(notes_fixture.note(i-3,i-2,name),sources=(f'p{i}',),event_id=f'p{i}') for i,name in enumerate(('c','d','e')))
source=types.SourcePhrase('pickup',F(),F(8),melody,(),'family',pickup)
harmony=harmonic.Harmony(passage_fixture.choices(('C7','C7')))
options=[notes for notes,edits in leads.variations(melody,source,harmony) if edits[0].kind=='pickup_entrance']
self.assertTrue(options)
self.assertEqual(options[0][0].start,0)
self.assertEqual(options[0][3].start,3)
self.assertEqual(options[0][3].end,5)
self.assertTrue(harmony.evaluate(options[0],complete=True).valid)
def test_occupancy_is_not_an_objective_and_active_silence_is_invalid(self):
plan=dataclasses.replace(notes_fixture.plan(),end=F(4),release=F(),passages=(),treatment='source_variation')
choices=passage_fixture.choices(('C7',))
for end in (F(1),F(4)):
self.assertEqual(scoring.features(plan,(notes_fixture.note(0,end,'e'),),choices)['occupancy'],0)
self.assertFalse(scoring.feasible(plan,(),choices,F(4)))
if __name__=='__main__':unittest.main()
-120
View File
@@ -1,120 +0,0 @@
"""Lead invention contracts and accepted musical examples, not taste metrics."""
import dataclasses
import fractions
import pathlib
import unittest
import generator.solo.placement
import generator.solo.references as references
import generator.solo.routes as routes
import generator.solo.realization as realization
import generator.solo.types as types
import generator.solo.drafts as drafts
import generator.solo.harmonic as harmonic
import generator.solo.scoring as scoring
import generator.solo.policy as policy
import generator.solo.sequences as sequences
import tests.scale_fixtures
import tests.test_solo_development as fixtures
import tests.test_solo_passages as passages
F = fractions.Fraction
def notes(names):
return tuple(dataclasses.replace(fixtures.note(F(i, 2), F(i + 1, 2), name), event_id=f'n{i}') for i, name in enumerate(names))
class LeadReferenceTests(unittest.TestCase):
def test_authored_sounding_phrase_is_valid_without_audition_files(self):
original=tuple(dataclasses.replace(n,start=n.start*2,end=n.end*2) for n in notes(('c','e','g','e')))
harmony=harmonic.Harmony(passages.choices(('CM7',)))
self.assertTrue(harmony.evaluate(original,complete=True).valid)
self.assertTrue(generator.solo.placement.place(original,F(120)))
def test_feature_selection_finds_the_relationship_not_the_first_three(self):
original = notes(('c', 'd', 'e', 'c', 'g', 'f', 'd'))
feature, = references.identifying_features(original, 'test')
self.assertNotEqual(feature.events[:3], tuple(n.event_id for n in original[:3]))
self.assertEqual(references.feature_error(feature, original), 0)
self.assertGreater(references.feature_error(feature, original[:1]), 0)
def test_single_note_does_not_match_a_complete_tune(self):
phrase = types.Motif('lead', notes(('d', 'g', 'f', 'd')), F(2), ('Gm7',))
self.assertEqual(references.relationship(notes(('c',)), phrase), 1)
self.assertEqual(references.relationship(phrase.notes, phrase), 0)
def test_similarity_is_counted_once(self):
self.assertNotIn('fidelity', dict(policy.DEFAULT.weights))
self.assertEqual(scoring.value({'fidelity': 1, 'development': 0}), 0)
self.assertEqual(scoring.value({'development': 1}), 2)
def test_one_attack_deadband_is_not_a_new_density_target(self):
plan = dataclasses.replace(fixtures.plan(), end=F(4), release=F(), activity=F(1), passages=())
choices = passages.choices(('C7',))
four = notes(('e', 'g', 'e', 'g'))
five = notes(('e', 'g', 'e', 'g', 'e'))
self.assertEqual(scoring.features(plan, four, choices)['activity'], 0)
self.assertEqual(scoring.features(plan, five, choices)['activity'], 0)
self.assertEqual(policy.DEFAULT.lead_activity_range, (1.0, 1.5))
def test_generated_opening_is_editable_until_a_feature_is_committed(self):
original = notes(('c', 'g', 'f', 'e'))
draft = types.MelodicDraft('invented', F(), F(2), original, (), (), (), (), None, 'statement')
changed = (dataclasses.replace(original[0], pitch=fixtures.note(0, 1, 'e').pitch), *original[1:])
self.assertTrue(drafts.contract_valid(draft, changed)[0])
selected = dataclasses.replace(draft, features=references.identifying_features(original, 'selected'))
self.assertFalse(drafts.contract_valid(selected, changed)[0])
def test_ending_activity_only_broadens_the_selected_hold(self):
plan = dataclasses.replace(fixtures.plan(), end=F(16), release=F())
anchor = dataclasses.replace(passages.anchor(), earliest=F(15), latest=F(15), duration=F(1), hold_until=F(16))
chosen = drafts.ending_plan(plan, (anchor,))
arrivals = [p for p in chosen.passages if p.intent == 'sustained_arrival']
self.assertEqual([(p.start, p.end) for p in arrivals], [(15, 16)])
self.assertTrue(all(p.intent == 'active' for p in chosen.passages if p.end <= 15))
def test_zero_passage_origin_retains_the_full_activity_budget(self):
plan = dataclasses.replace(fixtures.plan(), start=F(16), end=F(32), passage_start=F(), passage_end=F(32), activity=F(9, 8), passages=(types.ActivitySpan(F(), F(32), 'active', F(9, 8), F(3, 4)),))
anchor = dataclasses.replace(passages.anchor(), earliest=F(30), latest=F(30), duration=F(2), hold_until=F(32))
chosen = drafts.ending_plan(plan, (anchor,))
self.assertEqual(sum(p.activity * (p.end - p.start) for p in chosen.passages), F(36))
class CompleteRouteTests(unittest.TestCase):
def test_complete_route_reaches_endpoint_without_nearest_note_repairs(self):
harmony = harmonic.Harmony(passages.choices(('C7',)))
library = sequences.Library((sequences.Sequence('descending', (-1, -1)), sequences.Sequence('wrong', (1, 1))))
template = notes(('g', 'c', 'e'))
result = routes.candidates(template, {'n0', 'n2'}, harmony, library, (55, 79))
# Explicit repeated-attack primitives can also complete a chord traversal.
# The authored route itself must survive intact, without nearest-note repair.
selected=next(r for r in result if r.sources==('descending',))
self.assertEqual([n.pitch.number() for n in selected.notes], [67, 65, 64])
self.assertTrue(all(r.destination==template[-1] for r in result))
self.assertTrue(all(harmony.evaluate(r.notes).valid for r in result))
def test_route_cannot_repair_a_forbidden_fixed_pitch(self):
harmony = harmonic.Harmony(passages.choices(('C7',)))
library = sequences.Library((sequences.Sequence('descending', (-1, -1)),))
self.assertEqual(routes.candidates(notes(('g', 'c', 'e')), {'n1'}, harmony, library, (55, 79)), ())
def test_collection_change_preserves_sounding_coordinate(self):
minor = tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')
aeolian = tests.scale_fixtures.library().scale('major', 6, 'D')
# C-sharp is absent in Aeolian: the first rising degree is actual D,
# rather than reinterpreting C-sharp's old harmonic-minor index.
self.assertEqual(routes.advance(aeolian, 61, 1)[0].number(), 62)
choices = list(passages.choices(('A7', 'Dm7'), duration=1, scale=minor))
choices[1] = dataclasses.replace(choices[1], scale=aeolian)
harmony = harmonic.Harmony(tuple(choices))
template = (dataclasses.replace(fixtures.note(0, F(1, 2), 'e'), event_id='a'), dataclasses.replace(fixtures.note(F(1, 2), 1, 'c', 1), event_id='b'), dataclasses.replace(fixtures.note(1, F(3, 2), 'd'), event_id='c'))
library = sequences.Library((sequences.Sequence('resolution', (-2, 1)),))
result = routes.candidates(template, {'a', 'c'}, harmony, library, (55, 79))
self.assertTrue(result)
self.assertEqual([n.pitch.number() for n in result[0].notes], [64, 61, 62])
self.assertEqual(len(result[0].spans), 2)
def test_whole_idea_replacement_can_change_an_ordinary_opening(self):
harmony = harmonic.Harmony(passages.choices(('C7', 'C7')))
plan = dataclasses.replace(fixtures.plan(), end=F(8), release=F(), passages=())
def phrase(names, identity):
actual = tuple(dataclasses.replace(n, start=2 * n.start, end=2 * n.end, event_id=identity + n.event_id) for n in notes(names))
group = types.Transformation(identity, '', '', 'statement', F(), F(8), tuple(n.event_id for n in actual), tuple((n.start, n.end) for n in actual), preserves=())
draft = types.MelodicDraft(identity, F(), F(8), actual, (group,), (), (), (), None, 'statement')
return realization.checked_phrase(plan, draft, actual, (), types.ContinuationState(''), harmony, F(120))
first = phrase(('c', 'c', 'c', 'e', 'g', 'g', 'g', 'e'), 'first')
donor = phrase(('e', 'g', 'e', 'c', 'e', 'g', 'f', 'e'), 'donor')
self.assertIsNotNone(first)
self.assertIsNotNone(donor)
revised = realization.replace_idea(first, donor, F(), F(8), types.ContinuationState(''), harmony, F(120))
self.assertIsNotNone(revised)
self.assertEqual([n.pitch for n in realization.emitted_notes(revised)], [n.pitch for n in realization.emitted_notes(donor)])
self.assertEqual(revised.edits[-1].kind, 'replace_idea')
+95 -182
View File
@@ -1,188 +1,101 @@
import dataclasses
import fractions
import unittest
import generator.harmony
import generator.model
import generator.scales.selection
import generator.scales.timeline
import generator.solo.harmonic
import generator.solo.motifs
import generator.solo.scoring
import generator.solo.types
import dataclasses, fractions, unittest
import generator.harmony, generator.model, generator.scales.selection, generator.scales.timeline
import generator.solo.harmonic, generator.solo.types
import tests.scale_fixtures
import tests.test_solo_development
F = fractions.Fraction
note = tests.test_solo_development.note
def choices(symbols, duration=4, scale=None):
scale = scale or tests.scale_fixtures.library().scale('major', 6, 'D')
return tuple(generator.scales.selection.Choice(generator.scales.timeline.ChordSpan(F(i * duration), F((i + 1) * duration), i + 1, str(i + 1), F(1), generator.model.key_from_fifths(-1), generator.harmony.parse_symbol(symbol)), scale, 'fixture') for i, symbol in enumerate(symbols))
from tests.solo_fixtures import note,choices
F=fractions.Fraction
def anchor(role='terminal', pitch='e', earliest=2, latest=2, duration=2, onset=False):
return generator.solo.types.Anchor('arrival', F(earliest), F(latest), note(0, 1, pitch).pitch, frozenset({4}), (55, 79), F(duration), role, 'C7', onset, F(4) if role == 'terminal' else None)
return generator.solo.types.Anchor('arrival', F(earliest), F(latest), note(0, 1, pitch).pitch, frozenset({4}), (55, 79), F(duration), role, 'C7', onset, F(4) if role == 'terminal' else None)
class HarmonicPassageTests(unittest.TestCase):
def test_same_collection_distinguishes_chord_function(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7', 'FM7', 'BbM7')))
self.assertEqual([s.roles.basic for s in harmony.spans], [frozenset(v) for v in ({7, 10, 2, 5}, {0, 4, 7, 10}, {5, 9, 0, 4}, {10, 2, 5, 9})])
def test_semantic_degrees_keep_alterations_omissions_suspensions_and_slash_bass(self):
for symbol in ('C7b5', 'C9', 'Csus4', 'C7no5', 'C/E', 'C/D'):
chord = generator.harmony.parse_symbol(symbol)
roles = generator.solo.harmonic.roles(chord)
self.assertFalse(roles.basic & roles.specified)
self.assertNotIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C/D')).basic)
self.assertIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C9')).specified)
self.assertIn(5, generator.solo.harmonic.roles(generator.harmony.parse_symbol('Csus4')).basic)
self.assertNotIn(7, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C7no5')).basic)
def test_ordinary_long_destination_requires_chord_member(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
self.assertTrue(harmony.evaluate((note(0, 2, 'e'),)).valid)
self.assertFalse(harmony.evaluate((note(0, 2, 'f'),)).valid)
self.assertLess(harmony.evaluate((note(0, 1, 'e'),)).exposure, harmony.evaluate((note(0, 1, 'f'),)).exposure)
def test_connected_passing_motion_is_permitted(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
notes = (note(0, F(1, 2), 'g'), note(F(1, 2), 1, 'f'), note(1, 2, 'e'))
self.assertEqual(harmony.connected_motion(notes, 1), 'passing')
self.assertTrue(harmony.evaluate(notes).valid)
self.assertEqual(harmony.evaluate(notes).exposure, 0)
def test_common_tone_hold_needs_no_reattack(self):
harmony = generator.solo.harmonic.Harmony(choices(('Em7b5', 'A7'), scale=tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')))
self.assertTrue(harmony.evaluate((note(2, 6, 'g'),)).valid)
def test_held_tension_detected_without_scale_change(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
notes = (note(2, 6, 'f'),)
self.assertFalse(harmony.evaluate(notes).valid)
self.assertEqual([v['function'] for v in harmony.describe(notes)], ['basic', 'mapped_motion'])
def test_prepared_suspension_requires_actual_resolution(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
treatment = generator.solo.types.HarmonicTreatment('suspension', 'suspension', 65, F(4), F(5), 64, F(5))
notes = (note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'e'))
self.assertTrue(harmony.evaluate(notes, (treatment,), complete=True).valid)
partial = (note(2, F(9, 2), 'f'),)
result = harmony.evaluate(partial, (treatment,), until=F(9, 2))
self.assertTrue(result.valid)
self.assertEqual(result.pending, (treatment,))
self.assertFalse(harmony.evaluate(partial, (treatment,), until=6, complete=True).valid)
self.assertFalse(harmony.evaluate((note(4, F(9, 2), 'f'), note(F(9, 2), 6, 'e')), (treatment,), complete=True).valid)
self.assertFalse(harmony.evaluate((note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'g')), (treatment,), complete=True).valid)
def test_symbol_specified_color_requires_a_plan(self):
harmony = generator.solo.harmonic.Harmony(choices(('C9',)))
notes = (note(0, 2, 'd'),)
color = generator.solo.types.HarmonicTreatment('ninth', 'color', 62, F(0), F(2))
self.assertFalse(harmony.evaluate(notes).valid)
self.assertTrue(harmony.evaluate(notes, (color,)).valid)
ordinary = generator.solo.harmonic.Harmony(choices(('C7',)))
self.assertFalse(ordinary.evaluate(notes, (color,)).valid)
def test_quotation_tension_preserved_and_reported(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
notes = (note(0, 2, 'f', role='quote'),)
self.assertTrue(harmony.evaluate(notes).valid)
report = harmony.describe(notes)
self.assertTrue(report[0]['protected'])
self.assertEqual(report[0]['function'], 'mapped_motion')
def test_partition_invariance_for_roles_duration_activity_and_score(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
whole = (note(2, 6, 'f'),)
split = (note(2, 3, 'f'), note(3, 4, 'f', continuation=True), note(4, 6, 'f', continuation=True))
self.assertEqual(harmony.evaluate(whole), harmony.evaluate(split))
self.assertEqual(harmony.describe(whole), harmony.describe(split))
plan = tests.test_solo_development.plan()
self.assertEqual(generator.solo.scoring.features(plan, whole, harmony.choices), generator.solo.scoring.features(plan, split, harmony.choices))
def test_intermediate_arrival_does_not_require_closure(self):
notes = (note(0, 1, 'g'), note(1, 2, 'e'), note(2, 3, 'f'), note(3, 4, 'g'))
middle = anchor('intermediate', earliest=1, latest=1, duration=1)
self.assertTrue(generator.solo.harmonic.anchors_valid((middle,), notes, F(4), complete=True)[0])
def test_terminal_touch_then_departure_is_not_closure(self):
terminal = dataclasses.replace(anchor(), duration=F(1, 2), hold_until=None)
notes = (note(2, F(5, 2), 'e'), note(F(5, 2), 4, 'g'))
self.assertFalse(generator.solo.harmonic.anchors_valid((terminal,), notes, F(4), complete=True, closure='closed')[0])
self.assertTrue(generator.solo.harmonic.anchors_valid((anchor(),), (note(2, 4, 'e'),), F(4), complete=True, closure='closed')[0])
def test_existing_hold_can_satisfy_anchor_only_when_allowed(self):
notes = (note(0, 4, 'e'),)
self.assertIsNotNone(generator.solo.harmonic.anchor_note(anchor(), notes))
self.assertIsNone(generator.solo.harmonic.anchor_note(anchor(onset=True), notes))
def test_single_unrelated_note_is_not_perfect_motif_evidence(self):
motif = generator.solo.types.Motif('idea', (note(0, 1, 'c'), note(1, 2, 'd'), note(2, 3, 'g')), F(3), ())
for treatment in ('contour_reference', 'rhythmic_reference', 'opening_reference', 'answer'):
self.assertGreater(generator.solo.scoring.motif_fidelity((note(0, 1, 'e'),), motif, treatment), 0)
def test_scale_index_round_trips_negative_and_octave_crossings(self):
for scale in (tests.scale_fixtures.library().scale('major', 6, 'D'), tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')):
for index in range(-21, 28):
self.assertEqual(generator.solo.types.scale_index(scale, generator.solo.types.scale_pitch(scale, index).number()), index)
scale = tests.scale_fixtures.library().scale('major', 6, 'D')
self.assertEqual(generator.solo.types.scale_index(scale, 65) - generator.solo.types.scale_index(scale, 64), 1)
class PassageStateTests(unittest.TestCase):
def test_source_phrases_carry_identity_and_completed_anchors(self):
import tests.test_solo
solo = tests.test_solo.compose(tests.test_solo.song(count=8, chords=('Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7')))
self.assertEqual((solo.passages[0].start, solo.passages[-1].end), (0, 32))
for phrase in solo.phrases:
self.assertTrue(phrase.draft.source_phrase)
self.assertTrue(phrase.state.home.chord_degrees)
self.assertTrue(all(generator.solo.harmonic.anchor_note(a, phrase.state.notes) for a in phrase.anchors))
self.assertTrue(generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=True, closure='closed')[0])
def test_development_references_realization_instead_of_source_template(self):
plan = tests.test_solo_development.plan()
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
gesture = generator.solo.types.Gesture('heard', F(0), F(4), 'statement', 'opening_reference')
notes = (note(0, 1, 'e'), note(1, 2, 'f'), note(2, 4, 'g'))
valid, _, realized = generator.solo.motifs.complete(gesture, plan.motif, notes, harmony)
self.assertTrue(valid)
following = dataclasses.replace(gesture, id='answer', start=F(4), end=F(8), relationship='answer')
self.assertEqual(generator.solo.motifs.reference(plan, following, (realized,)), realized)
self.assertEqual(realized.contour(), (1, 2))
self.assertNotEqual(realized.contour(), plan.motif.contour())
def test_concrete_pool_includes_continuation_from_actual_incoming_register(self):
import generator.solo.realization
import tests.solo_fixtures
plan = tests.test_solo_development.plan()
incoming = note(0, 4, 'c', octave=5)
state = generator.solo.types.ContinuationState('passage', (incoming,), last=incoming)
cell = generator.solo.realization.Cell(F(4), F(6), (('moving', ((F(0), F(1, 2)), (F(1, 2), F(1)), (F(1), F(3, 2)), (F(3, 2), F(2)))),))
pool = generator.solo.realization.candidates(cell, plan, choices(('C7', 'C7')), tests.solo_fixtures.library(), state)
self.assertTrue(pool)
self.assertTrue(any(c.notes[0].pitch.number() == incoming.pitch.number() for c in pool))
self.assertTrue(any(c.notes[0].continuation for c in pool))
def test_pending_suspension_guides_next_cells_candidate_pool(self):
import generator.solo.realization
import tests.solo_fixtures
harmony = choices(('Gm7', 'C7'))
held = note(2, F(9, 2), 'f')
contract = generator.solo.types.HarmonicTreatment('pending', 'suspension', 65, F(4), F(9, 2), 64, F(5))
state = generator.solo.types.ContinuationState('passage', (held,), treatments=(contract,), last=held)
cell = generator.solo.realization.Cell(F(9, 2), F(6), (('answer', ((F(0), F(1, 2)), (F(1, 2), F(1)), (F(1), F(3, 2)))),))
pool = generator.solo.realization.candidates(cell, tests.test_solo_development.plan(), harmony, tests.solo_fixtures.library(), state)
self.assertTrue(pool)
self.assertTrue(all(c.notes[0].pitch.number() == 64 for c in pool))
for candidate in pool:
checked = generator.solo.harmonic.Harmony(harmony).evaluate((*state.notes, *candidate.notes), candidate.treatments, until=6, complete=True)
self.assertTrue(checked.valid)
self.assertFalse(checked.pending)
def test_incompatible_anchor_cannot_retime_a_protected_quote(self):
import generator.solo.realization
import tests.test_solo
import tests.solo_fixtures
import tests.rhythm_fixtures
plan = tests.test_solo_development.plan()
demanded = generator.solo.types.Anchor('incompatible', F(1, 4), F(1, 4), plan.motif.notes[1].pitch, frozenset({plan.motif.notes[1].pitch.number() % 12}), plan.register, F(1, 2), 'opening', 'CM7', True)
plan = dataclasses.replace(plan, treatment='exact_quote', anchor_options=((demanded,),))
source = tests.test_solo.song(count=2, chords=('CM7',))
with self.assertRaises(generator.solo.realization.SearchExhausted):
generator.solo.realization.compose(plan, source['staves'][0]['measures'], tests.test_solo_development.choices(), tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), F(120))
def test_phrase_lookahead_does_not_double_count_shared_passage_prefix(self):
import generator.solo.composer
first = dataclasses.replace(tests.test_solo_development.plan(), passage_id='shared', start=F(0), end=F(4))
second = dataclasses.replace(first, id='second', start=F(4), end=F(8))
left = generator.solo.types.Phrase(first, (generator.solo.types.Decision(F(0), F(4), 'fixture', 'invented', (note(0, 4, 'c'),)),), 1.0)
right = generator.solo.types.Phrase(first, (generator.solo.types.Decision(F(0), F(4), 'fixture', 'invented', (note(0, 4, 'g'),)),), 0.5)
def realize(plan, previous):
if plan.id == first.id:
return (left, right)
score = 0.6 if previous.pitch.letter == 'c' else 0.7
continuation = dataclasses.replace(previous, start=F(4), end=F(8))
return tuple([generator.solo.types.Phrase(second, (generator.solo.types.Decision(F(4), F(8), 'fixture', 'answer', (continuation,)),), score)])
chosen = generator.solo.composer.choose_phrases((first, second), realize)
self.assertEqual(chosen[0], left)
def test_same_collection_distinguishes_chord_function(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7', 'FM7', 'BbM7')))
self.assertEqual([s.roles.basic for s in harmony.spans], [frozenset(v) for v in ({7, 10, 2, 5}, {0, 4, 7, 10}, {5, 9, 0, 4}, {10, 2, 5, 9})])
def test_semantic_degrees_keep_alterations_omissions_suspensions_and_slash_bass(self):
for symbol in ('C7b5', 'C9', 'Csus4', 'C7no5', 'C/E', 'C/D'):
chord = generator.harmony.parse_symbol(symbol)
roles = generator.solo.harmonic.roles(chord)
self.assertFalse(roles.basic & roles.specified)
self.assertNotIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C/D')).basic)
self.assertIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C9')).specified)
self.assertIn(5, generator.solo.harmonic.roles(generator.harmony.parse_symbol('Csus4')).basic)
self.assertNotIn(7, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C7no5')).basic)
def test_ordinary_long_destination_requires_chord_member(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
self.assertTrue(harmony.evaluate((note(0, 2, 'e'),)).valid)
self.assertFalse(harmony.evaluate((note(0, 2, 'f'),)).valid)
self.assertLess(harmony.evaluate((note(0, 1, 'e'),)).exposure, harmony.evaluate((note(0, 1, 'f'),)).exposure)
def test_connected_passing_motion_is_permitted(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
notes = (note(0, F(1, 2), 'g'), note(F(1, 2), 1, 'f'), note(1, 2, 'e'))
self.assertEqual(harmony.connected_motion(notes, 1), 'passing')
self.assertTrue(harmony.evaluate(notes).valid)
self.assertEqual(harmony.evaluate(notes).exposure, 0)
def test_common_tone_hold_needs_no_reattack(self):
harmony = generator.solo.harmonic.Harmony(choices(('Em7b5', 'A7'), scale=tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')))
self.assertTrue(harmony.evaluate((note(2, 6, 'g'),)).valid)
def test_held_tension_detected_without_scale_change(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
notes = (note(2, 6, 'f'),)
self.assertFalse(harmony.evaluate(notes).valid)
self.assertEqual([v['function'] for v in harmony.describe(notes)], ['basic', 'mapped_motion'])
def test_prepared_suspension_requires_actual_resolution(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
treatment = generator.solo.types.HarmonicTreatment('suspension', 'suspension', 65, F(4), F(5), 64, F(5))
notes = (note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'e'))
self.assertTrue(harmony.evaluate(notes, (treatment,), complete=True).valid)
partial = (note(2, F(9, 2), 'f'),)
result = harmony.evaluate(partial, (treatment,), until=F(9, 2))
self.assertTrue(result.valid)
self.assertEqual(result.pending, (treatment,))
self.assertFalse(harmony.evaluate(partial, (treatment,), until=6, complete=True).valid)
self.assertFalse(harmony.evaluate((note(4, F(9, 2), 'f'), note(F(9, 2), 6, 'e')), (treatment,), complete=True).valid)
self.assertFalse(harmony.evaluate((note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'g')), (treatment,), complete=True).valid)
def test_symbol_specified_color_requires_a_plan(self):
harmony = generator.solo.harmonic.Harmony(choices(('C9',)))
notes = (note(0, 2, 'd'),)
color = generator.solo.types.HarmonicTreatment('ninth', 'color', 62, F(0), F(2))
self.assertFalse(harmony.evaluate(notes).valid)
self.assertTrue(harmony.evaluate(notes, (color,)).valid)
ordinary = generator.solo.harmonic.Harmony(choices(('C7',)))
self.assertFalse(ordinary.evaluate(notes, (color,)).valid)
def test_quotation_tension_preserved_and_reported(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
notes = (note(0, 2, 'f', role='quote'),)
self.assertTrue(harmony.evaluate(notes).valid)
report = harmony.describe(notes)
self.assertTrue(report[0]['protected'])
self.assertEqual(report[0]['function'], 'mapped_motion')
def test_intermediate_arrival_does_not_require_closure(self):
notes = (note(0, 1, 'g'), note(1, 2, 'e'), note(2, 3, 'f'), note(3, 4, 'g'))
middle = anchor('intermediate', earliest=1, latest=1, duration=1)
self.assertTrue(generator.solo.harmonic.anchors_valid((middle,), notes, F(4), complete=True)[0])
def test_terminal_touch_then_departure_is_not_closure(self):
terminal = dataclasses.replace(anchor(), duration=F(1, 2), hold_until=None)
notes = (note(2, F(5, 2), 'e'), note(F(5, 2), 4, 'g'))
self.assertFalse(generator.solo.harmonic.anchors_valid((terminal,), notes, F(4), complete=True, closure='closed')[0])
self.assertTrue(generator.solo.harmonic.anchors_valid((anchor(),), (note(2, 4, 'e'),), F(4), complete=True, closure='closed')[0])
def test_existing_hold_can_satisfy_anchor_only_when_allowed(self):
notes = (note(0, 4, 'e'),)
self.assertIsNotNone(generator.solo.harmonic.anchor_note(anchor(), notes))
self.assertIsNone(generator.solo.harmonic.anchor_note(anchor(onset=True), notes))
def test_scale_index_round_trips_negative_and_octave_crossings(self):
for scale in (tests.scale_fixtures.library().scale('major', 6, 'D'), tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')):
for index in range(-21, 28):
self.assertEqual(generator.solo.types.scale_index(scale, generator.solo.types.scale_pitch(scale, index).number()), index)
scale = tests.scale_fixtures.library().scale('major', 6, 'D')
self.assertEqual(generator.solo.types.scale_index(scale, 65) - generator.solo.types.scale_index(scale, 64), 1)
-133
View File
@@ -1,133 +0,0 @@
import dataclasses
from fractions import Fraction as F
import unittest
from generator.solo import drafts, harmonic, phrasing, themes, types, leads, realization
from tests.test_solo_development import note
from tests.test_solo_passages import choices
from tests.test_solo_themes import melody
import tests.rhythm_fixtures
import tests.solo_fixtures
class SustainedPhrasing(unittest.TestCase):
def test_vocal_cutoff_does_not_strand_a_one_beat_final_phrase(self):
from generator.solo import analysis
from tests.test_solo import song
document=song(count=32,chords=('CM7',))
source=analysis.analyze(document)
harmony=harmonic.Harmony(choices(('CM7',)*32))
values=phrasing.window_options(source,document['staves'][0]['measures'],F(100),F(125),harmony,types.ContinuationState('',closures=5),limit=4)
self.assertEqual(values[0].end,125)
self.assertFalse(any(0<125-v.end<4 for v in values))
def test_production_cost_uses_complete_sounding_events_across_cells(self):
harmony=harmonic.Harmony(choices(('C7','C7')))
whole=(note(0,1,'g'),note(1,F(3,2),'f'),note(F(3,2),4,'e'))
split=(whole[0],whole[1],dataclasses.replace(whole[2],end=2),note(2,4,'e',continuation=True))
incoming=types.ContinuationState('')
from tests.test_composer_repair import evaluated
self.assertEqual(evaluated(whole,harmony),evaluated(split,harmony))
def test_route_cache_preserves_concrete_provenance_and_uncached_selection(self):
from generator.solo import routes,sequences,policy
harmony=harmonic.Harmony(choices(('C7',)))
notes=melody(((0,1,'g'),(1,2,'f'),(2,3,'e')))
library=tests.solo_fixtures.library()
raw=routes._candidates(notes,{'2'},harmony,library,(41,76))
expected=sequences.diverse(raw,
lambda r:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(r.notes,r.notes[1:])),r.id),
lambda r:sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(r.notes,r.notes[1:]))),policy.DEFAULT.join_alternatives)
cached=routes.candidates(notes,{'2'},harmony,library,(41,76))
self.assertEqual(tuple((r.id,r.notes) for r in expected),tuple((r.id,r.notes) for r in cached))
renamed=tuple(dataclasses.replace(n,event_id='other'+n.event_id,sources=('other-source',)) for n in notes)
changed=routes.candidates(renamed,{'other2'},harmony,library,(41,76))
self.assertEqual(len(harmony.route_cache),1)
self.assertTrue(all(n.sources==('other-source',) for r in changed for n in r.notes))
self.assertTrue(all(n.event_id.startswith('other') for r in changed for n in r.notes))
def test_extended_closing_unit_has_complete_timing_beyond_two_bars(self):
values=themes.rhythm_windows(tests.rhythm_fixtures.library(),'4/4',F(12),realization.SearchContext())
self.assertTrue(values)
self.assertTrue(any(any(a>=8 for a,b in pattern) for pattern,_ in values))
for pattern,_ in values:
self.assertTrue(all(0<=a<b<=12 for a,b in pattern))
self.assertTrue(all(b<=c for (a,b),(c,d) in zip(pattern,pattern[1:])))
def test_two_bar_retrieval_uses_the_richer_one_bar_vocabulary(self):
values=themes.rhythm_windows(tests.rhythm_fixtures.library(),'4/4',F(8),realization.SearchContext())
self.assertTrue(any(b-a==F(1,2) for pattern,_ in values for a,b in pattern))
def test_endpoint_ranking_distinguishes_major_seventh_from_dominant(self):
major=harmonic.Harmony(choices(('CM7',)*4))
dominant=harmonic.Harmony(choices(('C7',)*4))
self.assertLess(phrasing.endpoint_cost(F(16),F(),F(128),0,major,F(4)),
phrasing.endpoint_cost(F(16),F(),F(128),0,dominant,F(4)))
def test_protected_tail_is_sounding_time_not_an_immutable_whole_event(self):
original=(note(0,4,'e',event_id='arrival'),)
tail=types.SustainedTail(F(2),F(4),64)
draft=types.MelodicDraft('held',F(),F(4),original,(),(),(),(),None,'close',sustained_tails=(tail,))
revised=(note(0,1,'g',event_id='a'),note(1,2,'f',event_id='b'),note(2,4,'e',event_id='c'))
self.assertTrue(drafts.contract_valid(draft,revised)[0])
tied=(*revised[:-1],note(2,3,'e',event_id='c'),note(3,4,'e',event_id='c',continuation=True))
self.assertTrue(drafts.contract_valid(draft,tied)[0])
repeated=(*revised[:-1],note(2,3,'e',event_id='c'),note(3,4,'e',event_id='d'))
self.assertFalse(drafts.contract_valid(draft,repeated)[0])
def test_planned_endings_have_varied_timing_and_full_chord_permission(self):
harmony=harmonic.Harmony(choices(('Gm7','C7','FM7','BbM7')))
source=types.SourcePhrase('phrase',F(),F(16),(note(0,1,'d'),),(),'')
options=phrasing.endings(source,types.ContinuationState(''), 'acoustic',harmony,F(4))
self.assertTrue(options)
self.assertGreater(len({(a.earliest,a.duration) for a in options}),1)
for anchor in options:
self.assertGreaterEqual(anchor.duration,2)
self.assertLessEqual(anchor.duration,4)
self.assertIn(anchor.pitch.number()%12,harmony.members(anchor.earliest,anchor.hold_until))
self.assertTrue(harmony.evaluate((types.Note(anchor.earliest,anchor.hold_until,anchor.pitch),),complete=True).valid)
def test_rhythmic_development_keeps_a_heard_turn_without_copying_its_timing(self):
original=melody(((0,1,'d'),(1,2,'f'),(2,3,'e')))
home=types.RealizedMotif('home','seed',original,F(3),'introduce','contour')
ref=themes.home_reference(home,'develop_contour')
context=realization.SearchContext()
patterns=themes.patterns(ref,F(),F(8),tests.rhythm_fixtures.library(),'4/4',context,home=home,operation='develop_contour')
self.assertGreater(len({p[:3] for p,_ in patterns}),1)
actual=(note(0,F(1,2),'d',event_id='0'),note(F(1,2),2,'f',event_id='1'),note(2,3,'e',event_id='2'))
feature=themes.feature(ref,actual,treatment='develop_contour')
from generator.solo.references import feature_error
self.assertEqual(feature_error(feature,actual),0)
def test_repetition_remembers_recent_figures_but_not_tie_splits(self):
original=melody(((0,1,'d'),(1,2,'f'),(2,3,'e'),(3,4,'f')))
old=phrasing.heard(original,'introduce')
repeated=tuple(dataclasses.replace(n,start=n.start+8,end=n.end+8) for n in original)
figure=phrasing.heard(repeated,'develop_contour')
self.assertGreater(phrasing.repetition(figure,(old,),F(4)),phrasing.repetition(figure,(),F(4)))
returned=dataclasses.replace(figure,operation='return')
self.assertGreater(phrasing.repetition(returned,(old,old,old),F(4)),phrasing.repetition(returned,(old,),F(4)))
hold=(note(0,4,'e'),)
split=(note(0,2,'e'),note(2,4,'e',continuation=True))
self.assertEqual(phrasing.heard(hold,'close'),phrasing.heard(split,'close'))
self.assertEqual(phrasing.repetition(phrasing.heard(hold,'close'),(),F(4)),0)
def test_high_electric_state_proposes_a_lower_goal(self):
high=melody(((0,1,'c'),(1,2,'e'),(2,3,'d')))
high=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=5)) for n in high)
state=types.ContinuationState('',last=high[-1],history=(phrasing.heard(high,'develop_contour'),),register_peak=76)
self.assertLess(phrasing.register_goal(state,'electric',74),min(n.pitch.number() for n in high))
def test_internal_short_notes_can_be_elaborated_without_consuming_arrival(self):
notes=melody(((0,1,'g'),(1,2,'f'),(2,3,'g'),(3,5,'e')))
source=types.SourcePhrase('internal',F(),F(5),notes,(),'')
harmony=harmonic.Harmony(choices(('C7','C7')))
tails=(types.SustainedTail(F(3),F(5),64),)
options=leads.elaborate(notes,(),harmony,tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',source,sustain_entry=True,tails=tails)
self.assertTrue(options)
for actual,_,_ in options:
self.assertTrue(any(n.pitch.number()==64 and n.start<=3 and n.end>=5 for n in actual))
self.assertTrue(harmony.evaluate(actual,complete=True).valid)
if __name__=='__main__':unittest.main()
-147
View File
@@ -1,147 +0,0 @@
"""Source influence and realized-theme continuity are different contracts."""
import dataclasses
from fractions import Fraction as F
import unittest
from generator.solo import analysis, composer, drafts, harmonic, leads, references, realization, similarity, themes, types
import tests.test_solo as fixture
import tests.test_solo_development as notes_fixture
import tests.test_solo_passages as harmony_fixture
import tests.scale_fixtures
import tests.solo_fixtures
import tests.rhythm_fixtures
def melody(values):
return tuple(dataclasses.replace(notes_fixture.note(a,b,p),event_id=str(i),sources=(str(i),)) for i,(a,b,p) in enumerate(values))
class ThemeContracts(unittest.TestCase):
def test_selected_rhythm_does_not_also_require_source_pitches(self):
source=melody(((0,1,'c'),(1,2,'d'),(2,3,'e')))
ref=types.SourceReference('source','phrase',F(),F(3),source,'rhythmic_seed')
actual=melody(((8,9,'g'),(9,10,'e'),(10,11,'c')))
self.assertEqual(references.feature_error(themes.feature(ref,actual),actual),0)
contour=dataclasses.replace(ref,treatment='contour_seed')
self.assertGreater(references.feature_error(themes.feature(contour,actual),actual),0)
def test_a_single_event_is_not_evidence_of_a_multinote_feature(self):
source=melody(((0,1,'c'),(1,2,'d'),(2,3,'e')))
ref=types.SourceReference('source','phrase',F(),F(3),source,'rhythmic_seed')
contract=themes.feature(ref,source)
self.assertEqual(references.feature_error(contract,source[:1]),1)
self.assertEqual(references.feature_error(themes.feature(ref,source[:1]),source[:1]),1)
def test_copy_comparison_cannot_be_diluted_by_octaves_runs_or_reattacks(self):
source=melody(((0,2,'c'),(2,4,'e'),(4,6,'g'),(6,8,'c')))
shifted=leads.shift(source,1)
self.assertTrue(similarity.near_copy(shifted,source,F(),F(8))['near_copy'])
repeated=tuple(part for n in shifted for part in (dataclasses.replace(n,end=n.start+1),dataclasses.replace(n,start=n.start+1,event_id=n.event_id+'r')))
self.assertTrue(similarity.near_copy(repeated,source,F(),F(8))['near_copy'])
self.assertEqual(similarity.compare(repeated,source)['octave_insensitive'],1)
shortened=tuple(dataclasses.replace(n,end=n.end-F(1,2)) for n in shifted)
self.assertEqual(similarity.compare(shortened,source)['octave_insensitive'],.75)
relocated=tuple(dataclasses.replace(n,start=n.start+8,end=n.end+8) for n in shifted)
self.assertTrue(similarity.near_copy(relocated,source,F(8),F(16))['near_copy'])
def test_tied_partition_does_not_change_similarity(self):
source=melody(((0,2,'c'),(2,4,'e'),(4,6,'g'),(6,8,'c')))
split=tuple(piece for n in source for piece in (dataclasses.replace(n,end=n.start+1),dataclasses.replace(n,start=n.start+1,continuation=True)))
self.assertEqual(similarity.compare(source,source),similarity.compare(split,source))
def test_downward_cross_collection_steps_preserve_the_actual_target(self):
from generator.solo import routes
scale=tests.scale_fixtures.library().scale('major',6,'D')
harmony=harmonic.Harmony(harmony_fixture.choices(('Dm7',),scale=scale))
first=dataclasses.replace(notes_fixture.note(0,1,'c'),pitch=types.Pitch('c',F(1),4))
last=dataclasses.replace(notes_fixture.note(1,2,'b'),pitch=types.Pitch('b',F(-1),3))
step,=themes.relative_steps((first,last),harmony)
self.assertEqual(step,-2)
self.assertEqual(routes.advance(scale,first.pitch.number(),step)[0].number(),last.pitch.number())
def test_two_note_route_uses_exact_scale_displacement(self):
from generator.solo import routes
harmony=harmonic.Harmony(harmony_fixture.choices(('C7',)))
notes=melody(((0,1,'g'),(1,2,'e')))
options=routes.candidates(notes,{'1'},harmony,tests.solo_fixtures.library(),(41,76))
self.assertTrue(options)
self.assertTrue(all(r.notes[-1].pitch==notes[-1].pitch for r in options))
def test_elaboration_may_continue_its_entry_instead_of_reattacking(self):
notes=melody(((0,3,'g'),(3,4,'e')))
source=types.SourcePhrase('held_entry',F(),F(4),notes,(),'')
harmony=harmonic.Harmony(harmony_fixture.choices(('C7',)))
options=leads.elaborate(notes,(),harmony,tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',source,sustain_entry=True)
self.assertTrue(options)
for actual,edit,route in options:
self.assertEqual(actual[0].start,0)
self.assertGreater(actual[0].end,edit.start)
self.assertEqual(actual[:len(route.notes)],route.notes)
self.assertTrue(harmony.evaluate(actual,complete=True).valid)
class ThemeProduction(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.document=fixture.song(count=8,chords=('CM7',))
cls.analysis=analysis.analyze(cls.document)
cls.context=realization.SearchContext()
cls.kw=dict(scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),context=cls.context)
cls.solo=composer.compose(cls.analysis,cls.document['staves'][0]['measures'],'acoustic',F(32),**cls.kw)
def test_one_heard_theme_develops_across_source_boundaries(self):
self.assertEqual(self.solo.diagnostics['construction'],'thematic_recomposition')
self.assertEqual(len({p.state.home.id for p in self.solo.phrases}),1)
for phrase in self.solo.phrases:
self.assertTrue(drafts.contract_valid(phrase.draft,composer.phrase_notes(phrase))[0])
self.assertFalse(similarity.near_copy(phrase.refined.notes,themes.source_notes(self.analysis),phrase.plan.start,phrase.plan.end)['near_copy'])
self.assertIn(self.solo.phrases[0].plan.closure,('open','closed'))
self.assertTrue(all(p.evaluation and p.score==p.evaluation.total for p in self.solo.phrases))
self.assertEqual(self.solo.phrases[-1].anchors[-1].role,'terminal')
self.assertTrue(any(t.operation in ('develop_contour','develop_rhythm','continue','return') for p in self.solo.phrases for t in p.draft.transformations))
def test_library_routes_match_the_actual_complete_theme(self):
plain=composer.compose(self.analysis,self.document['staves'][0]['measures'],'acoustic',F(32),stage='plain',**self.kw)
# Joint rhythm/expression edits can replace every original route in this
# particular fixture. Require construction to use complete routes, then
# verify all retained provenance in both the plain and edited realizations.
self.assertTrue(any(p.routes for p in plain.phrases))
for phrase in (*plain.phrases,*self.solo.phrases):
heard={n.event_id:n for n in composer.phrase_notes(phrase)}
for route in phrase.routes:
for note in route.notes:
actual=heard[note.event_id]
self.assertEqual((note.start,note.end,note.pitch),(actual.start,actual.end,actual.pitch))
self.assertEqual(route.notes[-1].pitch,route.destination.pitch)
def test_report_can_compare_a_source_hold_cut_by_new_phrase_boundaries(self):
from generator.solo import report
phrases=[]
for i,p in enumerate(self.solo.phrases):
held=notes_fixture.note(p.plan.start,p.plan.end,'c',event_id='source_hold',continuation=i>0)
phrases.append(dataclasses.replace(p,initial=dataclasses.replace(p.initial,notes=(held,))))
solo=dataclasses.replace(self.solo,phrases=tuple(phrases))
detail=report.structure(solo)
self.assertEqual(len(detail['phrases']),len(phrases))
self.assertTrue(phrases[-1].initial.notes[0].continuation)
def test_incoming_pitch_changes_concrete_continuation_pool(self):
harmony=harmonic.Harmony(self.solo.choices)
home=self.solo.phrases[0].state.home
ref=themes.home_reference(home,'answer')
context=realization.SearchContext()
outputs=[]
for octave in (3,4):
prior=dataclasses.replace(notes_fixture.note(15,16,'e'),pitch=types.Pitch('e',F(),octave))
state=types.ContinuationState('prior',last=prior,home=home)
options=themes.unit_candidates(ref,F(16),F(24),state,harmony,self.kw['sequences'],self.kw['rhythms'],'4/4',context,home=home,operation='answer')
self.assertTrue(options)
outputs.append({u.notes[0].pitch.number() for u in options})
self.assertNotEqual(*outputs)
def test_deterministic_cache_and_actual_source_input(self):
repeated=composer.compose(self.analysis,self.document['staves'][0]['measures'],'acoustic',F(32),**self.kw)
self.assertEqual(self.solo.notes(),repeated.notes())
changed=dataclasses.replace(self.analysis,melody=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,letter='g')) for n in self.analysis.melody))
other=composer.compose(changed,self.document['staves'][0]['measures'],'acoustic',F(32),**self.kw)
self.assertNotEqual(leads.signature(self.solo.notes()),leads.signature(other.notes()))
if __name__=='__main__':unittest.main()