From 0e68f5364bb0196ab1b7087ecc186f5d7446c42b Mon Sep 17 00:00:00 2001 From: khodowany Date: Sun, 4 Oct 2026 18:48:02 -0500 Subject: [PATCH] Cleanup --- audition/dependency_check.py | 2 +- audition/solo_compare.py | 30 +- data/composition/grammar.json | 141 --- data/composition/roles.json | 24 +- .../{melodic_vocabulary.json => solo.json} | 271 +++-- docs/composer/active_path_audit.md | 39 +- docs/composer/commands.md | 5 +- docs/composer/data_migration.md | 42 +- docs/composer/dependency_proof.md | 16 +- docs/composer/evaluation_contract.md | 29 +- docs/composer/implementation_report.md | 122 ++- docs/composer/musical_capability_matrix.md | 33 +- generator/__main__.py | 2 +- generator/production.py | 13 +- generator/solo/analysis.py | 115 +-- generator/solo/arrangement.py | 55 -- generator/solo/composer.py | 169 +--- generator/solo/construction.py | 230 +++++ generator/solo/drafts.py | 414 -------- generator/solo/enclosures.py | 204 ---- generator/solo/expressions.py | 176 +--- generator/solo/features.py | 56 -- generator/solo/gesture_rhythm.py | 129 --- generator/solo/gestures.py | 149 --- generator/solo/leads.py | 451 --------- generator/solo/motifs.py | 56 -- generator/solo/phrasing.py | 253 ----- generator/solo/planning.py | 220 ----- generator/solo/policy.py | 68 +- generator/solo/realization.py | 935 ------------------ generator/solo/references.py | 79 -- generator/solo/report.py | 213 +--- generator/solo/rhythm.py | 62 ++ generator/solo/routes.py | 362 ++----- generator/solo/scoring.py | 219 +--- generator/solo/sequences.py | 35 - generator/solo/similarity.py | 92 -- generator/solo/themes.py | 567 ----------- generator/solo/types.py | 314 +----- generator/vocabulary.py | 35 +- tests/solo_fixtures.py | 34 + tests/test_composer_repair.py | 177 ---- tests/test_fret_policy.py | 20 - tests/test_independent_generation.py | 10 +- tests/test_joint_arrivals.py | 114 --- tests/test_phrase_composer.py | 130 +++ tests/test_rhythm_contracts.py | 72 ++ tests/test_six_parts.py | 39 +- tests/test_solo.py | 395 +++----- tests/test_solo_comparison.py | 72 +- tests/test_solo_development.py | 169 ---- tests/test_solo_drafts.py | 242 ----- tests/test_solo_enclosures.py | 258 ----- tests/test_solo_lead_tunes.py | 147 --- tests/test_solo_leads.py | 120 --- tests/test_solo_passages.py | 277 ++---- tests/test_solo_phrasing.py | 133 --- tests/test_solo_themes.py | 147 --- 58 files changed, 1416 insertions(+), 7567 deletions(-) delete mode 100644 data/composition/grammar.json rename data/composition/{melodic_vocabulary.json => solo.json} (55%) create mode 100644 generator/solo/construction.py delete mode 100644 generator/solo/drafts.py delete mode 100644 generator/solo/enclosures.py delete mode 100644 generator/solo/features.py delete mode 100644 generator/solo/gesture_rhythm.py delete mode 100644 generator/solo/gestures.py delete mode 100644 generator/solo/leads.py delete mode 100644 generator/solo/motifs.py delete mode 100644 generator/solo/phrasing.py delete mode 100644 generator/solo/planning.py delete mode 100644 generator/solo/realization.py delete mode 100644 generator/solo/references.py create mode 100644 generator/solo/rhythm.py delete mode 100644 generator/solo/similarity.py delete mode 100644 generator/solo/themes.py delete mode 100644 tests/test_composer_repair.py delete mode 100644 tests/test_joint_arrivals.py create mode 100644 tests/test_phrase_composer.py create mode 100644 tests/test_rhythm_contracts.py delete mode 100644 tests/test_solo_development.py delete mode 100644 tests/test_solo_drafts.py delete mode 100644 tests/test_solo_enclosures.py delete mode 100644 tests/test_solo_lead_tunes.py delete mode 100644 tests/test_solo_leads.py delete mode 100644 tests/test_solo_phrasing.py delete mode 100644 tests/test_solo_themes.py diff --git a/audition/dependency_check.py b/audition/dependency_check.py index ede2475..e78f986 100644 --- a/audition/dependency_check.py +++ b/audition/dependency_check.py @@ -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) diff --git a/audition/solo_compare.py b/audition/solo_compare.py index 8a54939..454c415 100644 --- a/audition/solo_compare.py +++ b/audition/solo_compare.py @@ -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 += ['

'+html.escape(role)+'

',f'

Exact event match: {"yes" if r["exact_musical_match"] else "no"}. {r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.

', '

Generated MusicXML · Untouched reference MusicXML

'] + if baseline_manifest: + baseline_path=Path(baseline_dir)/baseline_manifest['solos'][role]['file'] + rows.append('

Previous generated MusicXML

') selected=collections.Counter(c['kind'] for c in r['production_phrases'].get('expressions',[])) rows.append('

Selected, validated production treatments: '+html.escape(str(dict(selected)))+'. Ordinary mapped material remains separate.

') if r.get('reference_events_after_production_end'): rows.append('

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.

') - 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('
Construction coverage (not similarity scoring)') + stages=('eligible_contexts','proposed','rhythm_valid','complete_routes','valid','finalists','placed','lookahead_valid','selected') + rows.append('
Construction coverage (not similarity scoring)
PurposeProposed structuresRetained jobsValid routesRetained units
'+''.join('' for k in stages)+'') for purpose,counts in sorted(funnel.items()): - rows.append(''+''.join('' for k in ('proposed','retained_jobs','valid_routes','retained_units'))+'') + rows.append(''+''.join('' for k in stages)+'') rows.append('
Purpose'+k.replace('_',' ')+'
'+html.escape(purpose)+''+str(counts[k])+'
'+html.escape(purpose)+''+str(counts.get(k,0))+'
') for clip in r.get('audition',{}).get('clips',[]): rows.append('

'+html.escape(clip['version']+' / '+clip['context'])+'

') @@ -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() diff --git a/data/composition/grammar.json b/data/composition/grammar.json deleted file mode 100644 index 8720f62..0000000 --- a/data/composition/grammar.json +++ /dev/null @@ -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 - } - } -} diff --git a/data/composition/roles.json b/data/composition/roles.json index a584a4a..3e284de 100644 --- a/data/composition/roles.json +++ b/data/composition/roles.json @@ -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": { diff --git a/data/composition/melodic_vocabulary.json b/data/composition/solo.json similarity index 55% rename from data/composition/melodic_vocabulary.json rename to data/composition/solo.json index 7e7300b..d2613e4 100644 --- a/data/composition/melodic_vocabulary.json +++ b/data/composition/solo.json @@ -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 } } diff --git a/docs/composer/active_path_audit.md b/docs/composer/active_path_audit.md index 5cfe169..014535f 100644 --- a/docs/composer/active_path_audit.md +++ b/docs/composer/active_path_audit.md @@ -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. diff --git a/docs/composer/commands.md b/docs/composer/commands.md index d65ff74..f428f62 100644 --- a/docs/composer/commands.md +++ b/docs/composer/commands.md @@ -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. diff --git a/docs/composer/data_migration.md b/docs/composer/data_migration.md index 72ccc14..ba99ad9 100644 --- a/docs/composer/data_migration.md +++ b/docs/composer/data_migration.md @@ -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. diff --git a/docs/composer/dependency_proof.md b/docs/composer/dependency_proof.md index fbac7f7..f63bca6 100644 --- a/docs/composer/dependency_proof.md +++ b/docs/composer/dependency_proof.md @@ -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. diff --git a/docs/composer/evaluation_contract.md b/docs/composer/evaluation_contract.md index 1b10c6f..baaf261 100644 --- a/docs/composer/evaluation_contract.md +++ b/docs/composer/evaluation_contract.md @@ -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. diff --git a/docs/composer/implementation_report.md b/docs/composer/implementation_report.md index b517937..bd0c7cf 100644 --- a/docs/composer/implementation_report.md +++ b/docs/composer/implementation_report.md @@ -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. diff --git a/docs/composer/musical_capability_matrix.md b/docs/composer/musical_capability_matrix.md index d26a015..cf61fec 100644 --- a/docs/composer/musical_capability_matrix.md +++ b/docs/composer/musical_capability_matrix.md @@ -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. diff --git a/generator/__main__.py b/generator/__main__.py index f1900d8..1ac4625 100644 --- a/generator/__main__.py +++ b/generator/__main__.py @@ -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 diff --git a/generator/production.py b/generator/production.py index 04d5ae8..d6b25f6 100644 --- a/generator/production.py +++ b/generator/production.py @@ -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 diff --git a/generator/solo/analysis.py b/generator/solo/analysis.py index 61a3a74..2bd1ca4 100644 --- a/generator/solo/analysis.py +++ b/generator/solo/analysis.py @@ -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)]) diff --git a/generator/solo/arrangement.py b/generator/solo/arrangement.py index e8428e7..6d47df4 100644 --- a/generator/solo/arrangement.py +++ b/generator/solo/arrangement.py @@ -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) diff --git a/generator/solo/composer.py b/generator/solo/composer.py index 7c5ec62..ba58746 100644 --- a/generator/solo/composer.py +++ b/generator/solo/composer.py @@ -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') diff --git a/generator/solo/construction.py b/generator/solo/construction.py new file mode 100644 index 0000000..7209844 --- /dev/null +++ b/generator/solo/construction.py @@ -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]<=time1 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.startcenter+7:shift=min(0,shift) + if incoming and incoming.pitch.number()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-entrymovement_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 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} diff --git a/generator/solo/enclosures.py b/generator/solo/enclosures.py deleted file mode 100644 index 3112a95..0000000 --- a/generator/solo/enclosures.py +++ /dev/null @@ -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.startend:return None # never clip a pre-existing target sustain - if cursorstart and (n.event_id in ids or n.role=='quote') for n in draft.notes) and not any(t.startstart 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 startstart or not editable(draft,first.end,target.start):continue - templates=tuple(n for n in notes if first.start<=n.start=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) diff --git a/generator/solo/expressions.py b/generator/solo/expressions.py index 91bd11e..9be9c82 100644 --- a/generator/solo/expressions.py +++ b/generator/solo/expressions.py @@ -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 startF(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.start2 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.start0)-(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) diff --git a/generator/solo/gesture_rhythm.py b/generator/solo/gesture_rhythm.py deleted file mode 100644 index b59e548..0000000 --- a/generator/solo/gesture_rhythm.py +++ /dev/null @@ -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.startinside[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=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=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) diff --git a/generator/solo/gestures.py b/generator/solo/gestures.py deleted file mode 100644 index adfeec1..0000000 --- a/generator/solo/gestures.py +++ /dev/null @@ -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.startend) - 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+1min(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=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=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) diff --git a/generator/solo/leads.py b/generator/solo/leads.py deleted file mode 100644 index 375cbef..0000000 --- a/generator/solo/leads.py +++ /dev/null @@ -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= 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=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.startnote.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.startsource.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=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.start0) - result.append(dataclasses.replace(source,end=finish,notes=notes,rests=tuple((a,min(b,finish)) for a,b in source.rests if astart: - 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 diff --git a/generator/solo/motifs.py b/generator/solo/motifs.py deleted file mode 100644 index 14dc994..0000000 --- a/generator/solo/motifs.py +++ /dev/null @@ -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) diff --git a/generator/solo/phrasing.py b/generator/solo/phrasing.py deleted file mode 100644 index 86b77ec..0000000 --- a/generator/solo/phrasing.py +++ /dev/null @@ -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= 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<=startstart),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 0start) - 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.start12: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=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 () diff --git a/generator/solo/planning.py b/generator/solo/planning.py deleted file mode 100644 index 2c18c85..0000000 --- a/generator/solo/planning.py +++ /dev/null @@ -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) diff --git a/generator/solo/policy.py b/generator/solo/policy.py index 7114cb7..a6a70ec 100644 --- a/generator/solo/policy.py +++ b/generator/solo/policy.py @@ -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() diff --git a/generator/solo/realization.py b/generator/solo/realization.py deleted file mode 100644 index 79a366d..0000000 --- a/generator/solo/realization.py +++ /dev/null @@ -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] diff --git a/generator/solo/references.py b/generator/solo/references.py deleted file mode 100644 index efcde92..0000000 --- a/generator/solo/references.py +++ /dev/null @@ -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 diff --git a/generator/solo/report.py b/generator/solo/report.py index b3d1b3b..45bcc5a 100644 --- a/generator/solo/report.py +++ b/generator/solo/report.py @@ -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=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 diff --git a/generator/solo/rhythm.py b/generator/solo/rhythm.py new file mode 100644 index 0000000..9aef09b --- /dev/null +++ b/generator/solo/rhythm.py @@ -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-a0,any(a[1]=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 diff --git a/generator/solo/routes.py b/generator/solo/routes.py index 3971690..6e76b0c 100644 --- a/generator/solo/routes.py +++ b/generator/solo/routes.py @@ -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.start12: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.startfirst.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)first.number())-(destination=.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 00)-(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) diff --git a/generator/solo/scoring.py b/generator/solo/scoring.py index 0d3fea2..2f04dac 100644 --- a/generator/solo/scoring.py +++ b/generator/solo/scoring.py @@ -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=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)}) diff --git a/generator/solo/sequences.py b/generator/solo/sequences.py index 8ded309..4460ef8 100644 --- a/generator/solo/sequences.py +++ b/generator/solo/sequences.py @@ -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) diff --git a/generator/solo/similarity.py b/generator/solo/similarity.py deleted file mode 100644 index 2b5a8e6..0000000 --- a/generator/solo/similarity.py +++ /dev/null @@ -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=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) - count_in=sum(left<=a+offsetbest: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)) diff --git a/generator/solo/themes.py b/generator/solo/themes.py deleted file mode 100644 index 72ca7f6..0000000 --- a/generator/solo/themes.py +++ /dev/null @@ -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)=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 totaltotal or any(alength: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) - if cell.exit=='continue' and offset+total=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)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()=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=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-cursorfinish-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.startedit.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 diff --git a/generator/solo/types.py b/generator/solo/types.py index 2f2949a..4b9852b 100644 --- a/generator/solo/types.py +++ b/generator/solo/types.py @@ -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) diff --git a/generator/vocabulary.py b/generator/vocabulary.py index 1e04f46..9d2c240 100644 --- a/generator/vocabulary.py +++ b/generator/vocabulary.py @@ -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') diff --git a/tests/solo_fixtures.py b/tests/solo_fixtures.py index faab1b0..e152bad 100644 --- a/tests/solo_fixtures.py +++ b/tests/solo_fixtures.py @@ -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)) diff --git a/tests/test_composer_repair.py b/tests/test_composer_repair.py deleted file mode 100644 index 22d6c3d..0000000 --- a/tests/test_composer_repair.py +++ /dev/null @@ -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)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)) diff --git a/tests/test_fret_policy.py b/tests/test_fret_policy.py index 3676c61..da2ba32 100644 --- a/tests/test_fret_policy.py +++ b/tests/test_fret_policy.py @@ -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))) diff --git a/tests/test_independent_generation.py b/tests/test_independent_generation.py index de23a5c..79fe912 100644 --- a/tests/test_independent_generation.py +++ b/tests/test_independent_generation.py @@ -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} diff --git a/tests/test_joint_arrivals.py b/tests/test_joint_arrivals.py deleted file mode 100644 index 220d8ae..0000000 --- a/tests/test_joint_arrivals.py +++ /dev/null @@ -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() diff --git a/tests/test_phrase_composer.py b/tests/test_phrase_composer.py new file mode 100644 index 0000000..3a5e8ff --- /dev/null +++ b/tests/test_phrase_composer.py @@ -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 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('Cmajor-seventh' + ''.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('Cmajor-seventh' + ''.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()) diff --git a/tests/test_solo_comparison.py b/tests/test_solo_comparison.py index cdf8580..8f55dbe 100644 --- a/tests/test_solo_comparison.py +++ b/tests/test_solo_comparison.py @@ -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= 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) diff --git a/tests/test_solo_drafts.py b/tests/test_solo_drafts.py deleted file mode 100644 index 2645597..0000000 --- a/tests/test_solo_drafts.py +++ /dev/null @@ -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) diff --git a/tests/test_solo_enclosures.py b/tests/test_solo_enclosures.py deleted file mode 100644 index beda164..0000000 --- a/tests/test_solo_enclosures.py +++ /dev/null @@ -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() diff --git a/tests/test_solo_lead_tunes.py b/tests/test_solo_lead_tunes.py deleted file mode 100644 index b0912e9..0000000 --- a/tests/test_solo_lead_tunes.py +++ /dev/null @@ -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() diff --git a/tests/test_solo_leads.py b/tests/test_solo_leads.py deleted file mode 100644 index b90d748..0000000 --- a/tests/test_solo_leads.py +++ /dev/null @@ -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') diff --git a/tests/test_solo_passages.py b/tests/test_solo_passages.py index 2e8af1a..e5d9d95 100644 --- a/tests/test_solo_passages.py +++ b/tests/test_solo_passages.py @@ -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) diff --git a/tests/test_solo_phrasing.py b/tests/test_solo_phrasing.py deleted file mode 100644 index c7a7162..0000000 --- a/tests/test_solo_phrasing.py +++ /dev/null @@ -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=5 for n in actual)) - self.assertTrue(harmony.evaluate(actual,complete=True).valid) - - -if __name__=='__main__':unittest.main() diff --git a/tests/test_solo_themes.py b/tests/test_solo_themes.py deleted file mode 100644 index 89cd9d8..0000000 --- a/tests/test_solo_themes.py +++ /dev/null @@ -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()