This commit is contained in:
khodowany
2026-10-03 10:12:12 -05:00
parent 5fff5a165e
commit 59afd78205
14 changed files with 666 additions and 41 deletions
+10 -2
View File
@@ -101,20 +101,28 @@ Source analysis retains complete sounding notes, rests, pickups and cross-bar ho
Treatment-specific contracts preserve the selected features only. Whole-source deviation applies to close paraphrase, including sounding durations; it does not penalize recomposition for changing unrelated notes. Before optional runs, realized pitch/time overlap and interval/rhythm fragments reject substantial near-copies (initial settings: 80% agreement across a two-bar window with at least four source attacks). Octave shifts, reattacks and extra attacks cannot dilute the duration comparison. Additional distance earns no bonus. The original source reference and the solo's realized theme remain separate in state and reports. Treatment-specific contracts preserve the selected features only. Whole-source deviation applies to close paraphrase, including sounding durations; it does not penalize recomposition for changing unrelated notes. Before optional runs, realized pitch/time overlap and interval/rhythm fragments reject substantial near-copies (initial settings: 80% agreement across a two-bar window with at least four source attacks). Octave shifts, reattacks and extra attacks cannot dilute the duration comparison. Additional distance earns no bonus. The original source reference and the solo's realized theme remain separate in state and reports.
Scale permission and chord function remain distinct. Every generated sound must belong to all mapped collections it overlaps. Added chromatic substitutions and their validation exception have been removed; mapped accidentals such as C-sharp in D harmonic minor remain valid. Basic chord members respect suspensions, alterations and omissions; slash-bass requirements do not automatically become melodic targets. Ordinary sounds lasting at least two quarter-note beats must remain chord members across every overlapping chord, unless an explicit valid color or prepared suspension applies. Tied notation is merged before evaluation. Terminal anchors must actually complete, and protected quotations retain their contracts. Scale permission and chord function remain distinct. Ordinary melody and runs must belong to all mapped collections they overlap; mapped accidentals such as C-sharp in D harmonic minor remain valid. A separate, complete enclosure contract can authorize one brief lower approach outside that map. Generic chromatic substitutions remain disabled. Basic chord members respect suspensions, alterations and omissions; slash-bass requirements do not automatically become melodic targets. Ordinary sounds lasting at least two quarter-note beats must remain chord members across every overlapping chord, unless an explicit valid color or prepared suspension applies. Tied notation is merged before evaluation. Terminal anchors must actually complete, and protected quotations retain their contracts.
Rhythm snippets and complete interval routes realize one- or two-bar edits jointly with chord-tone destinations. Routes retain their entry, complete timing, pitch path and arrival across barlines and collection changes. Later candidates receive the incoming note, position and realized theme before pitch generation. Run entries may extend an existing note without reattack. Intermediate arrivals continue the thought; the final destination has a closed-ending contract. Missing harmony creates explicit silence while preserving thematic memory. Short fragments around missing harmony are explicitly reduced and do not claim full motif fidelity. Rhythm snippets and complete interval routes realize one- or two-bar edits jointly with chord-tone destinations. Routes retain their entry, complete timing, pitch path and arrival across barlines and collection changes. Later candidates receive the incoming note, position and realized theme before pitch generation. Run entries may extend an existing note without reattack. Intermediate arrivals continue the thought; the final destination has a closed-ending contract. Missing harmony creates explicit silence while preserving thematic memory. Short fragments around missing harmony are explicitly reduced and do not claim full motif fidelity.
Destination retrieval retains representatives of the actual chord degrees before narrowing timing/register choices. Thirds, sevenths, specified sixths, suspensions and altered fifths receive a small harmonic-function preference; roots and fifths remain eligible. The same bounded target cost considers incoming motion, recent destinations, the theme's direction and one following harmony. Terminal roots of the mapped tonic also remain competitive. A Dm triad does not acquire Dm7's seventh, and a non-chord slash bass does not become a melody target.
After selecting the strict acoustic/electric pair and its mapped runs, `solo/enclosures.py` compares sparse local ornaments with direct arrivals. The initial vocabulary is a mapped upper neighbor followed by a lower semitone neighbor, each an eighth note, then the exact preselected chord-tone target. The upper is at most three semitones above the target. The lower can already be mapped; chromatic status earns no extra reward. Each candidate replaces a continuously sounding beat before an existing intermediate stop or terminal hold, optionally revising a complete mapped route up to two beats earlier. Identifying features, protected quotations, rests, target timing and the whole required hold remain intact. No target is reattacked merely to make an ornament possible.
Validation checks the complete sounded gesture, all crossed harmony/scale spans, exact pitch and octave, arrival and release, and actual preparation/resolution timing. Only the contracted lower event can receive an exception. Missing, changed, clipped or unresolved targets invalidate the entire gesture. Tied splits do not change classification. A small chorus frontier retains direct options, with at most one enclosure per phrase and four per 32 bars; sparse use is a soft preference, not a quota. The home theme and development history stay strict. Placement is resolved across the final solo, and backing responds to the decorated foreground. Build-time `enclosures_enabled=False` on compose/compose_pair/arrangement skips only this final pass; no runtime fields or performance expression are added.
The plain recomposed theme is exported independently of optional runs. Activity retains a soft 4–6 attacks-per-four-beats preference; occupancy remains diagnostic. No accents, dynamics, swing or other performance-expression changes are applied. The plain recomposed theme is exported independently of optional runs. Activity retains a soft 4–6 attacks-per-four-beats preference; occupancy remains diagnostic. No accents, dynamics, swing or other performance-expression changes are applied.
Search retains eight phrase alternatives and an eight-state chorus frontier, with two intermediate continuations per established theme. Up to 64 acoustic/electric pairs compete; equivalent themes after transposition are excluded while individual musical costs and pair similarity remain separate. Concrete caches include incoming state, harmony, timing and vocabulary. One bounded replan broadens eligible start and destination pitches. Exhaustion identifies the failed window without invoking a generic composer. Stable ordering and final tie-breaking preserve determinism. Search retains eight phrase alternatives and an eight-state chorus frontier, with two intermediate continuations per established theme. Up to 64 acoustic/electric pairs compete; equivalent themes after transposition are excluded while individual musical costs and pair similarity remain separate. Concrete caches include incoming state, harmony, timing and vocabulary. One bounded replan broadens eligible start and destination pitches. Exhaustion identifies the failed window without invoking a generic composer. Stable ordering and final tie-breaking preserve determinism.
Phrase register alternatives cover the available F2–E5 sounding range. Placement uses all six strings, frets 1–12 without open notes, with a four-fret hand-position model and alternative endpoint fingerings. Movement costs use available time; they do not claim a maximum playable speed. Equal-cost tie-breaking has no first-string preference. Chords elsewhere retain their existing 1–12 anchor policy. The other guitarist is recomposed around the selected foreground, and the returning vocal pickup remains protected. Phrase register alternatives cover the available F2–E5 sounding range. Placement uses all six strings, frets 1–12 without open notes, with a four-fret hand-position model and alternative endpoint fingerings. Movement costs use available time; they do not claim a maximum playable speed. Equal-cost tie-breaking has no first-string preference. Chords elsewhere retain their existing 1–12 anchor policy. The other guitarist is recomposed around the selected foreground, and the returning vocal pickup remains protected.
Build reports in `generated_assets/analysis/` include selected source features, recurring solo themes, development lineage, anchors, complete routes, plain-versus-head and elaborated-versus-head comparisons, and candidate diagnostics. Audition reports additionally show string use weighted by sounding duration and distinguish forced first-string notes from optional placements. These measurements establish technical behavior, not musical quality. Build reports in `generated_assets/analysis/` include selected source features, recurring solo themes, development lineage, anchors, complete routes, plain-versus-head and elaborated-versus-head comparisons, candidate diagnostics, destination degrees and complete enclosure contracts. Out-of-map approach notes are reported separately. Audition reports additionally show string use weighted by sounding duration and distinguish forced first-string notes from optional placements. These measurements establish technical behavior, not musical quality.
For comparisons, preserve the previous `elaborated/snapshot.json` under `build/solo_melody_auditions/paraphrase_baseline/`, then run `./modulus.py python -m generator.solo.lead_audition --capture` and `--render`. Open `build/solo_melody_auditions/index.html` for the original source, previous close paraphrase, new theme and theme with runs, alone and with fixed backing. Full final mixes also use responsive accompaniment. Source controls preserve the imported melody and may expose original harmonic tensions; production variations must pass strict validation. The temporary reference archive remains ignored at `build/lead_melody_reference.zip`. For comparisons, preserve the previous `elaborated/snapshot.json` under `build/solo_melody_auditions/paraphrase_baseline/`, then run `./modulus.py python -m generator.solo.lead_audition --capture` and `--render`. Open `build/solo_melody_auditions/index.html` for the original source, previous close paraphrase, new theme and theme with runs, alone and with fixed backing. Full final mixes also use responsive accompaniment. Source controls preserve the imported melody and may expose original harmonic tensions; production variations must pass strict validation. The temporary reference archive remains ignored at `build/lead_melody_reference.zip`.
The current enclosure comparison is under `build/solo_enclosure_auditions/index.html`: previous production, the new selected strict solo, and that same solo with enclosures, with fixed backing and the final responsive arrangement. `before_enclosure` retains the complete pre-ornament realization, distinct from the plain theme before runs. To capture a new comparison, preserve the prior snapshot as `before/snapshot.json`, then run `./modulus.py python -m generator.solo.audition --output build/solo_enclosure_auditions --capture after` and `--render`. `--no-enclosures` provides a strict capture. These build artifacts are ignored by Git.
To inspect generated assets without building the full package, run: To inspect generated assets without building the full package, run:
```sh ```sh
+2 -2
View File
@@ -85,7 +85,7 @@ def backing(baseline, foreground, part, rhythms, references, choices, initial=No
generator.model.validate_document(document) generator.model.validate_document(document)
return document['staves'][0]['measures'] return document['staves'][0]['measures']
def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None, references=(), stage='elaborated'): 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.""" """Compose once per song, replacing only already-placed solo section windows."""
analysis = generator.solo.analysis.analyze(source) analysis = generator.solo.analysis.analyze(source)
sections = regions(source) sections = regions(source)
@@ -105,7 +105,7 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
if sounding: if sounding:
cutoff -= generator.model.fraction(pickup['duration']) - min(sounding) cutoff -= generator.model.fraction(pickup['duration']) - min(sounding)
requests[part] = (template, cutoff, initial) requests[part] = (template, cutoff, initial)
selected = generator.solo.composer.compose_pair(analysis, requests, scales=scales, sequences=sequences, rhythms=rhythms, contexts=contexts, stage=stage) 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')): for solo, section_index, other in zip(selected, (3, 4), ('electric', 'acoustic')):
part = solo.part part = solo.part
first, count, offset, length = sections[section_index] first, count, offset, length = sections[section_index]
+26 -8
View File
@@ -78,13 +78,13 @@ def fingerprint():
def resources(): def resources():
return (generator.rhythm.load_library(pathlib.Path('data/rhythms')), generator.scales.library.load_library(pathlib.Path('data/scales')), generator.solo.sequences.load_library(pathlib.Path('data/sequences'))) return (generator.rhythm.load_library(pathlib.Path('data/rhythms')), generator.scales.library.load_library(pathlib.Path('data/scales')), generator.solo.sequences.load_library(pathlib.Path('data/sequences')))
def capture(root, stage, libraries=None, contexts=None): def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=True):
destination = root / stage destination = root / stage
if destination.exists(): if destination.exists():
raise ValueError(f'{destination}: snapshot already exists; choose a new output root') raise ValueError(f'{destination}: snapshot already exists; choose a new output root')
rhythms, scales, sequences = libraries or resources() rhythms, scales, sequences = libraries or resources()
reference_library = generator.solo.references.load_library(pathlib.Path('data/lead_phrases')) reference_library = generator.solo.references.load_library(pathlib.Path('data/lead_phrases'))
result = {'stage': stage, 'implementation': fingerprint(), 'reference_versions': [r.version for r in reference_library], 'songs': {}} result = {'enclosures_enabled':enclosures_enabled,'stage': stage, 'implementation': fingerprint(), 'reference_versions': [r.version for r in reference_library], 'songs': {}}
try: try:
result['policy'] = importlib.import_module('generator.solo.policy').describe() result['policy'] = importlib.import_module('generator.solo.policy').describe()
except ModuleNotFoundError: except ModuleNotFoundError:
@@ -94,7 +94,7 @@ def capture(root, stage, libraries=None, contexts=None):
checkpoint = root / f'.{stage}_{path.stem}_checkpoint.json' checkpoint = root / f'.{stage}_{path.stem}_checkpoint.json'
if checkpoint.exists(): if checkpoint.exists():
saved = json.loads(checkpoint.read_text()) saved = json.loads(checkpoint.read_text())
if saved['implementation'] == result['implementation'] and saved.get('reference_versions') == result['reference_versions'] and saved['song']['source_sha256'] == source_hash: if saved.get('enclosures_enabled',True)==enclosures_enabled and saved['implementation'] == result['implementation'] and saved.get('reference_versions') == result['reference_versions'] and saved['song']['source_sha256'] == source_hash:
result['songs'][path.stem] = saved['song'] result['songs'][path.stem] = saved['song']
result.setdefault('resumed_songs', []).append(path.stem) result.setdefault('resumed_songs', []).append(path.stem)
continue continue
@@ -107,7 +107,7 @@ def capture(root, stage, libraries=None, contexts=None):
if shared: if shared:
for context in shared.values(): for context in shared.values():
context.diagnostics = generator.solo.realization.SearchContext().diagnostics context.diagnostics = generator.solo.realization.SearchContext().diagnostics
generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared, references=reference_library) generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared, references=reference_library,enclosures_enabled=enclosures_enabled)
song = {'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'parts': parts, 'solos': {}, 'elapsed_seconds': time.monotonic() - started} song = {'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'parts': parts, 'solos': {}, 'elapsed_seconds': time.monotonic() - started}
regions = generator.solo.arrangement.regions(document) regions = generator.solo.arrangement.regions(document)
for solo, index in zip(solos, (3, 4)): for solo, index in zip(solos, (3, 4)):
@@ -135,7 +135,7 @@ def capture(root, stage, libraries=None, contexts=None):
drafts.append({'id': phrase.plan.id, 'draft_id': phrase.draft.id, 'first': begin, 'count': stop - begin, 'document': view}) drafts.append({'id': phrase.plan.id, 'draft_id': phrase.draft.id, 'first': begin, 'count': stop - begin, 'document': view})
song['solos'][solo.part]['draft_documents'] = drafts song['solos'][solo.part]['draft_documents'] = drafts
stages = {} stages = {}
for stage_name in ('initial', 'refined'): for stage_name in ('initial', 'refined', 'before_enclosure'):
if not all(getattr(p, stage_name) for p in solo.phrases): if not all(getattr(p, stage_name) for p in solo.phrases):
continue continue
notes = tuple(n for p in solo.phrases for n in getattr(p, stage_name).notes) notes = tuple(n for p in solo.phrases for n in getattr(p, stage_name).notes)
@@ -157,7 +157,7 @@ def capture(root, stage, libraries=None, contexts=None):
print(path.stem, solo.part, json.dumps({k: v for k, v in song['solos'][solo.part]['metrics'].items() if k != 'search'}), flush=True) print(path.stem, solo.part, json.dumps({k: v for k, v in song['solos'][solo.part]['metrics'].items() if k != 'search'}), flush=True)
result['songs'][path.stem] = song result['songs'][path.stem] = song
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
checkpoint.write_text(json.dumps({'implementation': result['implementation'], 'reference_versions': result['reference_versions'], 'song': song}) + '\n') checkpoint.write_text(json.dumps({'enclosures_enabled':enclosures_enabled,'implementation': result['implementation'], 'reference_versions': result['reference_versions'], 'song': song}) + '\n')
destination.mkdir(parents=True) destination.mkdir(parents=True)
(destination / 'snapshot.json').write_text(json.dumps(result, indent=2) + '\n') (destination / 'snapshot.json').write_text(json.dumps(result, indent=2) + '\n')
@@ -240,6 +240,19 @@ def render(root):
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache) samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
clips.append((path, samples)) clips.append((path, samples))
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'window': label, 'first': offset, 'count': count, 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']}) manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'window': label, 'first': offset, 'count': count, 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
if stage == 'after' and 'before_enclosure' in solo.get('lead_stages', {}):
strict = excerpt(solo['lead_stages']['before_enclosure']['document'], offset, count)
strict_prefix=prefix.with_name(prefix.name+'_strict')
generator.musicxml_writer.write_musicxml(strict_prefix.with_suffix('.musicxml'),strict)
backing=excerpt(baseline['parts'][other],first,count)
vocal=excerpt(baseline['parts']['vocal'],first,count)
path=strict_prefix.with_name(strict_prefix.name+'_fixed').with_suffix('.wav')
clips.append((path,mix((strict,backing,vocal),fractions.Fraction(solo['tempo']),root,cache)))
manifest['clips'].append({'path':path.relative_to(root).as_posix(),'kind':'same_selected_theme_before_enclosures','window':label})
if label=='full':
path=strict_prefix.with_name(strict_prefix.name+'_solo').with_suffix('.wav')
clips.append((path,mix((strict,),fractions.Fraction(solo['tempo']),root,cache)))
manifest['clips'].append({'path':path.relative_to(root).as_posix(),'kind':'strict_unaccompanied','window':label})
if stage == 'after' and 'refined' in solo.get('lead_stages', {}): if stage == 'after' and 'refined' in solo.get('lead_stages', {}):
plain = excerpt(solo['lead_stages']['refined']['document'], offset, count) plain = excerpt(solo['lead_stages']['refined']['document'], offset, count)
plain_prefix = prefix.with_name(prefix.name + '_plain') plain_prefix = prefix.with_name(prefix.name + '_plain')
@@ -328,13 +341,17 @@ def write_passage_index(root, snapshots):
for stage in ('before', 'after'): for stage in ('before', 'after'):
metric = snapshots[stage]['songs'][name]['solos'][part]['metrics'] metric = snapshots[stage]['songs'][name]['solos'][part]['metrics']
body.append(f"<p>{stage.title()}: {metric['attacks_per_four_beats']:.2f} attacks / four beats; {metric['sounding_occupancy']:.1%} occupancy; longest unplanned gap {metric['longest_unplanned_gap_beats']:g} beats.</p>") body.append(f"<p>{stage.title()}: {metric['attacks_per_four_beats']:.2f} attacks / four beats; {metric['sounding_occupancy']:.1%} occupancy; longest unplanned gap {metric['longest_unplanned_gap_beats']:g} beats.</p>")
strict_available='before_enclosure' in song['solos'][part].get('lead_stages',{})
plain_available='refined' in song['solos'][part].get('lead_stages',{}) plain_available='refined' in song['solos'][part].get('lead_stages',{})
body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>After, responsive backing</th>' + ('<th>Plain theme, fixed backing</th>' if plain_available else '') + '</tr>') body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>After, responsive backing</th>' + ('<th>Same selected solo, without enclosures</th>' if strict_available else '') + ('<th>Plain theme, fixed backing</th>' if plain_available else '') + '</tr>')
for window in ('full', 'opening8', 'closing4'): for window in ('full', 'opening8', 'closing4'):
body.append(f'<tr><th>{window}</th>') body.append(f'<tr><th>{window}</th>')
for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')): for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')):
prefix = stage + '/' + name + '_' + part + '_' + window prefix = stage + '/' + name + '_' + part + '_' + window
body.append(f'<td><audio controls preload="none" src="{prefix}_{mix_name}.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a>' + (f' · <a href="{prefix}.json">Passage data</a>' if stage == 'after' else '') + '</small></td>') body.append(f'<td><audio controls preload="none" src="{prefix}_{mix_name}.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a>' + (f' · <a href="{prefix}.json">Passage data</a>' if stage == 'after' else '') + '</small></td>')
if strict_available:
prefix=f'after/{name}_{part}_{window}_strict'
body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">Strict notation</a></small></td>')
if plain_available: if plain_available:
prefix=f'after/{name}_{part}_{window}_plain' prefix=f'after/{name}_{part}_{window}_plain'
body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">Plain theme notation</a></small></td>') body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">Plain theme notation</a></small></td>')
@@ -352,13 +369,14 @@ def main():
action.add_argument('--capture', choices=(*STAGES, 'before', 'after', 'phrase', 'revised')) action.add_argument('--capture', choices=(*STAGES, 'before', 'after', 'phrase', 'revised'))
action.add_argument('--compare-phrases', action='store_true', help='capture both new stages using shared concrete draft pools') action.add_argument('--compare-phrases', action='store_true', help='capture both new stages using shared concrete draft pools')
action.add_argument('--render', action='store_true') action.add_argument('--render', action='store_true')
parser.add_argument('--no-enclosures',action='store_true',help='retain the identical selected strict melody before ornamentation')
args = parser.parse_args() args = parser.parse_args()
if args.compare_phrases: if args.compare_phrases:
libraries, contexts = resources(), {} libraries, contexts = resources(), {}
capture(args.output, 'phrase', libraries, contexts) capture(args.output, 'phrase', libraries, contexts)
capture(args.output, 'revised', libraries, contexts) capture(args.output, 'revised', libraries, contexts)
elif args.capture: elif args.capture:
capture(args.output, args.capture) capture(args.output, args.capture,enclosures_enabled=not args.no_enclosures)
else: else:
render(args.output) render(args.output)
+17 -6
View File
@@ -2,6 +2,9 @@ from __future__ import annotations
import dataclasses import dataclasses
import fractions import fractions
import time
import generator.solo.enclosures
import generator.fretboard import generator.fretboard
import generator.harmony import generator.harmony
@@ -15,7 +18,8 @@ import generator.solo.policy
import generator.solo.realization import generator.solo.realization
import generator.solo.types import generator.solo.types
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated', mode='thematic'): def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated', mode='thematic', enclosures_enabled=True):
started = time.monotonic()
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial) timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo') region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
choices = generator.scales.selection.select(timeline, region, scales) choices = generator.scales.selection.select(timeline, region, scales)
@@ -26,13 +30,16 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
context = context if context is not None else generator.solo.realization.SearchContext() context = context if context is not None else generator.solo.realization.SearchContext()
options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode) options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode)
result = options[0][1] result = options[0][1]
result.diagnostics['strict_composition_seconds'] = time.monotonic()-started
result = generator.solo.enclosures.refine(result,sequences,enclosures_enabled and stage=='elaborated' and mode=='thematic')
validate(result) validate(result)
return result return result
def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic'): def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic', enclosures_enabled=True):
"""Select two recognizable interpretations together, before arranging backing.""" """Select two recognizable interpretations together, before arranging backing."""
from generator.solo import leads from generator.solo import leads
started = time.monotonic()
pools = {} pools = {}
for part, (measures,end,initial) in requests.items(): for part, (measures,end,initial) in requests.items():
timeline = generator.scales.timeline.read({'measures': measures},initial=initial) timeline = generator.scales.timeline.read({'measures': measures},initial=initial)
@@ -52,10 +59,15 @@ def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=Non
raise generator.solo.realization.SearchExhausted('Distinct acoustic/electric themes not found within the bounded pair search') raise generator.solo.realization.SearchExhausted('Distinct acoustic/electric themes not found within the bounded pair search')
_,acoustic,electric = min(pairs,key=lambda v:(v[0],generator.solo.policy.stable_rank('solo-pair',(v[1].notes(),v[2].notes())))) _,acoustic,electric = min(pairs,key=lambda v:(v[0],generator.solo.policy.stable_rank('solo-pair',(v[1].notes(),v[2].notes()))))
overlap = leads.similarity(acoustic,electric) overlap = leads.similarity(acoustic,electric)
strict_seconds=time.monotonic()-started
selected=[]
for solo in (acoustic,electric): for solo in (acoustic,electric):
solo.diagnostics.update(pair_similarity=overlap,pairs_compared=len(pairs),solo_alternatives=len(pools[solo.part])) solo.diagnostics.update(pair_similarity=overlap,pairs_compared=len(pairs),solo_alternatives=len(pools[solo.part]))
solo.diagnostics['strict_pair_seconds']=strict_seconds
solo=generator.solo.enclosures.refine(solo,sequences,enclosures_enabled and stage=='elaborated' and mode=='thematic')
validate(solo) validate(solo)
return acoustic,electric selected.append(solo)
return tuple(selected)
def phrase_notes(phrase): def phrase_notes(phrase):
@@ -120,8 +132,6 @@ def validate(solo):
for note in solo.notes(): 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: 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}') raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
if note.pitch.number() % 12 not in generator.solo.types.permitted(solo.choices, note.start, note.end):
raise ValueError(f'{solo.part} solo: note outside mapped collection at {note.start}')
if note.position not in generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())): if note.position not in generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())):
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}') raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
previous = note previous = note
@@ -131,7 +141,8 @@ def validate(solo):
raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized') raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized')
harmony = generator.solo.harmonic.Harmony(solo.choices) 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)) contracts = tuple(dict.fromkeys(t for phrase in solo.phrases if phrase.state for t in phrase.state.treatments))
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True) enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures)
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures)
if not checked.valid: if not checked.valid:
raise ValueError(f'{solo.part} solo: {checked.error}') raise ValueError(f'{solo.part} solo: {checked.error}')
for phrase in solo.phrases: for phrase in solo.phrases:
+192
View File
@@ -0,0 +1,192 @@
"""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()) for p in (upper,lower)):return None
identity=target.event_id+':enclosure'
return (types.Note(t-1,t-F(1,2),upper,target.sources,'enclosure_upper',event_id=identity+':upper'),
types.Note(t-F(1,2),t,lower,target.sources,'enclosure_lower',event_id=identity+':lower'))
def declared_targets(phrase,harmony):
"""Declare eligible existing stops before proposing their ornaments."""
if not phrase.draft:return ()
terminal={a.pitch.number():a for a in phrase.anchors if a.role=='terminal'}
values=[]
held=phrase.draft.notes[0].event_id if phrase.draft.notes and phrase.draft.notes[0].continuation else None
for note in drafts.local_sounding(phrase.draft.notes):
if note.event_id==held:continue
if note.continuation or note.role=='quote' or note.end-note.start<1:continue
if note.pitch.number()%12 not in harmony.members(note.start,note.end):continue
anchor=terminal.get(note.pitch.number())
role='terminal' if anchor and anchor.earliest<=note.start<=anchor.latest else 'intermediate'
rank=(role!='terminal',phrasing.destination_cost(note.pitch,note.start,note.end,harmony,terminal=role=='terminal'),-(note.end-note.start),note.start)
values.append((rank,note,role))
return tuple((n,role) for _,n,role in sorted(values)[:policy.DEFAULT.enclosure_targets])
def replace_window(notes,start,end,replacement):
"""Only rewrite continuously sounding material; preserve meaningful space."""
cursor=start;prefix=[];suffix=[]
for note in notes:
if note.end<=start:prefix.append(note);continue
if note.start>=end:suffix.append(note);continue
if note.start>cursor:return None
cursor=max(cursor,note.end)
if note.start<start:prefix.append(dataclasses.replace(note,end=start))
if note.end>end:return None # never clip a pre-existing target sustain
if cursor<end:return None
return (*prefix,*replacement,*suffix)
def editable(draft,start,end):
ids=set((*draft.structural,*draft.protected,*(e for f in draft.features for e in f.events)))
return not any(n.start<end and n.end>start and (n.event_id in ids or n.role=='quote') for n in draft.notes) and not any(t.start<end and t.end>start for t in draft.sustained_tails)
def movement(notes):
return sum(max(0,abs(b.pitch.number()-a.pitch.number())-3)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1)
def candidates(phrase,harmony,library,tempo,rejections):
draft=phrase.draft
if not draft:return ()
if draft.notes and draft.notes[0].continuation:
# This local budget does not reopen a committed preceding phrase. Never
# invent a new attack by detaching its already-sounding entrance.
rejections.append({'target':draft.notes[0].event_id,'reason':'cross-phrase sustain retained; no local rewrite of its context'})
return ()
notes=types.sounding_notes(draft.notes);result=[]
for target,role in declared_targets(phrase,harmony):
start=target.start-1
if start<phrase.plan.start or not editable(draft,start,target.start):
rejections.append({'target':target.event_id,'reason':'protected material or no editable approach window'});continue
pair=approach(target,harmony)
if not pair:
rejections.append({'target':target.event_id,'reason':'no mapped upper neighbor or playable approach'});continue
upper,lower=pair
contract=types.EnclosureContract(target.event_id+':enclosure',upper,lower,target,harmony.at(target.start).chord,role,
types.SustainedTail(target.start,target.end,target.pitch.number()))
proposals=[(start,pair,None)]
# Reopen up to two beats before the approach as a whole in-map route. Keep
# the incoming attack and fixed upper endpoint, not independently chosen steps.
for first in reversed(notes):
if not start-2<=first.start<start or first.end>start or not editable(draft,first.end,target.start):continue
templates=tuple(n for n in notes if first.start<=n.start<start)
if not templates or any(a.end!=b.start for a,b in zip(templates,templates[1:])) or templates[-1].end!=start:continue
for route in routes.candidates((*templates,upper),{upper.event_id},harmony,library,(41,76),'mapped approach into enclosure')[:policy.DEFAULT.enclosure_routes]:
proposals.append((first.start,(*route.notes,lower),route))
break
accepted=0
for begin,replacement,route in proposals[:1+policy.DEFAULT.enclosure_routes]:
actual=replace_window(notes,begin,target.start,replacement)
if actual is None:continue
valid,error=drafts.contract_valid(draft,actual)
if not valid:continue
checked=harmony.evaluate(actual,phrase.state.treatments if phrase.state else (),until=phrase.plan.end,complete=True,enclosures=(contract,))
if not checked.valid:continue
valid,error=harmonic.anchors_valid(phrase.anchors,actual,phrase.plan.end,complete=True,closure=phrase.plan.closure)
if not valid:continue
placed=placement.place(actual,tempo)
if placed is None:continue
old_travel=placement.place(notes,tempo)[0]
delta=.6*(movement(actual)-movement(notes))+.025*(placed[0]-old_travel)/max(1,len(notes))/2000
density=4*len(actual)/float(phrase.plan.end-phrase.plan.start)
before_density=4*len(notes)/float(phrase.plan.end-phrase.plan.start)
delta+=.1*(max(0,density-6)-max(0,before_density-6))
transforms=tuple(dataclasses.replace(t,events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end)) for t in draft.transformations)
changed=dataclasses.replace(draft,notes=actual,transformations=transforms,enclosures=(contract,))
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in actual if d.start<=n.start<d.end)) for d in phrase.decisions)
if tuple(n for d in decisions for n in d.notes)!=actual:continue
retained=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
result.append((delta,dataclasses.replace(phrase,draft=changed,decisions=decisions,before_enclosure=draft,
routes=(*retained,*((route,) if route else ())))))
accepted+=1
if not accepted:rejections.append({'target':target.event_id,'reason':'no valid complete enclosure within local route budget'})
return tuple(result)
def strict(solo):
"""Recover the exact selected pre-enclosure notes, including their placement."""
phrases=[]
for phrase in solo.phrases:
draft=phrase.before_enclosure
if not draft:phrases.append(phrase);continue
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in draft.notes if d.start<=n.start<d.end)) for d in phrase.decisions)
state=dataclasses.replace(phrase.state,notes=draft.notes,last=draft.notes[-1] if draft.notes else phrase.state.last) if phrase.state else None
phrases.append(dataclasses.replace(phrase,draft=draft,decisions=decisions,state=state))
return dataclasses.replace(solo,phrases=tuple(phrases),diagnostics={**solo.diagnostics,'enclosures_enabled':False})
def refine(solo,library,enabled=True):
started=time.monotonic();harmony=harmonic.Harmony(solo.choices);rejections=[]
baseline=[]
for phrase in solo.phrases:
draft=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes)) if phrase.draft else None
baseline.append(dataclasses.replace(phrase,draft=draft,before_enclosure=draft))
baseline=tuple(baseline)
if not enabled:
return dataclasses.replace(solo,phrases=baseline,diagnostics={**solo.diagnostics,'enclosures_enabled':False,'enclosure_seconds':time.monotonic()-started})
maximum=math.ceil(policy.DEFAULT.enclosure_max_per_32_bars*float(solo.end)/128)
low,high=2*float(solo.end)/128,3*float(solo.end)/128
states=[(0.,(),())];options_count=0
def sparse(count):return .10*max(0,low-count)+.12*max(0,count-high)
for phrase in baseline:
options=((0.,phrase),*candidates(phrase,harmony,library,solo.tempo,rejections));options_count+=len(options)-1
following=[]
for cost,chosen,arrivals in states:
for delta,option in options:
enclosure=option.draft.enclosures[0] if option.draft and option.draft.enclosures else None
if enclosure and len(arrivals)>=maximum:continue
spacing=.08*max(0,1-float(enclosure.target.start-arrivals[-1][0])/16) if enclosure and arrivals else 0.
repeated=.06*sum(pc==enclosure.target.pitch.number()%12 for _,pc in arrivals) if enclosure else 0.
history=(*arrivals,(enclosure.target.start,enclosure.target.pitch.number()%12)) if enclosure else arrivals
following.append((cost+delta+spacing+repeated,(*chosen,option),history))
states=sorted(following,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2])))[:policy.DEFAULT.enclosure_frontier]
_,phrases,arrivals=min(states,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2])))
# Resolve the complete guitar path once, including both sides of phrase joins.
raw=tuple(n for p in phrases for d in p.decisions for n in d.notes)
placed=placement.place(raw,solo.tempo)
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)
+66 -5
View File
@@ -92,9 +92,65 @@ class Harmony:
if self.step(a, b, note.start) and self.step(b, c, note.start): if self.step(a, b, note.start) and self.step(b, c, note.start):
return 'neighbor' if a == c else 'passing' if (b - a) * (c - b) > 0 else '' return 'neighbor' if a == c else 'passing' if (b - a) * (c - b) > 0 else ''
return '' return ''
def evaluate(self, notes, treatments=(), *, until=None, complete=False): def enclosure_events(self, notes, enclosures, until=None):
"""Validate whole gestures before granting any exception; never trust labels."""
notes = generator.solo.types.sounding_notes(notes)
events = {}
for note in notes:
if note.event_id:
if note.event_id in events:
return {}, 'duplicate sounding event ID in enclosure context'
events[note.event_id] = note
accepted, intervals = {}, []
for contract in enclosures:
upper, lower, target = contract.upper, contract.lower, contract.target
if len({n.event_id for n in (upper, lower, target)}) != 3 or not all(n.event_id for n in (upper, lower, target)):
return {}, contract.id + ': enclosure requires three distinct event IDs'
for expected in (upper, lower, target):
actual = events.get(expected.event_id)
if not actual or (actual.start, actual.end, actual.pitch) != (expected.start, expected.end, expected.pitch):
return {}, contract.id + ': missing or changed enclosure event ' + expected.event_id
if upper.end-upper.start != fractions.Fraction(1,2) or lower.end-lower.start != fractions.Fraction(1,2) or upper.end != lower.start or lower.end != target.start:
return {}, contract.id + ': enclosure must be two contiguous eighths immediately before its target'
if not 0 < upper.pitch.number()-target.pitch.number() <= 3 or lower.pitch.number() != target.pitch.number()-1:
return {}, contract.id + ': enclosure does not surround the exact target register'
if any(n.start < target.start and n.end > upper.start and n.event_id not in (upper.event_id,lower.event_id) for n in notes):
return {}, contract.id + ': interrupted or overlapping enclosure'
if any(a < target.start and b > upper.start for a,b in intervals):
return {}, contract.id + ': overlapping enclosure contracts'
if until is not None and target.end > until:
return {}, contract.id + ': enclosure resolution exceeds the available arrangement'
span = self.at(target.start)
tail = contract.protected_tail
if not span or span.chord != contract.chord or target.pitch.number()%12 not in self.members(target.start,target.end):
return {}, contract.id + ': target is not a chord member throughout its hold'
if contract.target_role not in ('intermediate','terminal') or tail.pitch != target.pitch.number() or not target.start <= tail.start < tail.end <= target.end:
return {}, contract.id + ': invalid protected destination tail'
for note in (upper, lower, target):
covered=note.start
for region,start,end in self.segments(note):
if start != covered or not region.scale:
return {}, contract.id + ': missing harmony in enclosure'
if note is not lower and note.pitch.number()%12 not in region.scale.pitches:
return {}, contract.id + ': upper approach or target outside mapped collection'
covered=end
if covered != note.end:
return {}, contract.id + ': incomplete harmony in enclosure'
# The upper neighbor is the nearest common mapped pitch above T.
allowed=generator.solo.types.permitted(self.choices,upper.start,upper.end)
if any(p%12 in allowed for p in range(target.pitch.number()+1,upper.pitch.number())):
return {}, contract.id + ': upper approach is not the nearest mapped neighbor'
accepted[lower.event_id]=contract
accepted[upper.event_id]=contract
intervals.append((upper.start,target.start))
return accepted, ''
def evaluate(self, notes, treatments=(), *, until=None, complete=False, enclosures=()):
notes = generator.solo.types.sounding_notes(notes) notes = generator.solo.types.sounding_notes(notes)
until = until if until is not None else max((n.end for n in notes), default=fractions.Fraction()) until = until if until is not None else max((n.end for n in notes), default=fractions.Fraction())
ornaments, error = self.enclosure_events(notes, enclosures, until) if enclosures else ({}, '')
if error:
return Result(False, 0, error=error)
pending, valid_contracts = [], [] pending, valid_contracts = [], []
for treatment in treatments: for treatment in treatments:
if treatment.start >= until: if treatment.start >= until:
@@ -130,11 +186,13 @@ class Harmony:
covered = note.start covered = note.start
for span, start, end in self.segments(note): for span, start, end in self.segments(note):
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None) contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None)
if start > covered or not span.scale or number % 12 not in span.scale.pitches: enclosure = ornaments.get(note.event_id)
exception = enclosure is not None and note.event_id == enclosure.lower.event_id
if start > covered or not span.scale or number % 12 not in span.scale.pitches and not exception:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony') return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
covered = end covered = end
function = self.function(number, span) function = self.function(number, span)
if function == 'basic' or note.role == 'quote': if function == 'basic' or note.role == 'quote' or enclosure:
continue continue
if duration >= generator.solo.policy.DEFAULT.long_note and contract is None: if duration >= generator.solo.policy.DEFAULT.long_note and contract is None:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment') return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment')
@@ -144,13 +202,16 @@ class Harmony:
if covered < note.end: if covered < note.end:
return Result(False, 0, error=f'{note.pitch.label()} at {covered}: missing harmonic coverage') return Result(False, 0, error=f'{note.pitch.label()} at {covered}: missing harmonic coverage')
return Result(True, exposed / max(sounding, 0.001), tuple(pending)) return Result(True, exposed / max(sounding, 0.001), tuple(pending))
def describe(self, notes, treatments=()): def describe(self, notes, treatments=(), enclosures=()):
notes = generator.solo.types.sounding_notes(notes) notes = generator.solo.types.sounding_notes(notes)
ornaments, error = self.enclosure_events(notes,enclosures) if enclosures else ({}, '')
if error:
raise ValueError(error)
result = [] result = []
for index, note in enumerate(notes): for index, note in enumerate(notes):
for span, start, end in self.segments(note): for span, start, end in self.segments(note):
contracts = [t for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end] contracts = [t for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]}) result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'enclosure': ornaments[note.event_id].id if note.event_id in ornaments else '', 'scale_exception': note.event_id in ornaments and self.function(note.pitch.number(), span) == 'outside_collection', 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]})
return result return result
def anchor_note(anchor, notes, *, until=None): def anchor_note(anchor, notes, *, until=None):
+61 -3
View File
@@ -123,10 +123,10 @@ def endings(source, incoming, part, harmony, bar, *, limit=8):
harmony.members(arrival,arrival+hold),(41,76),hold,'terminal',harmony.at(arrival).chord, harmony.members(arrival,arrival+hold),(41,76),hold,'terminal',harmony.at(arrival).chord,
hold_until=arrival+hold) hold_until=arrival+hold)
cadence=sum(h.ending==(pitch.number()%12,arrival%4,hold) for h in incoming.history) cadence=sum(h.ending==(pitch.number()%12,arrival%4,hold) for h in incoming.history)
rank=abs(pitch.number()-goal)/24+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release) rank=destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release)
values.append((rank,anchor)) values.append((rank,anchor))
selected=sequences.diverse(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])), selected=destination_options(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
lambda x:(x[1].duration,x[1].earliest%bar,x[1].pitch.number()>goal),limit) lambda x:x[1].pitch,lambda x:x[1].earliest,harmony,limit)
return tuple(a for _,a in selected) return tuple(a for _,a in selected)
@@ -159,3 +159,61 @@ def evaluate(notes, incoming, harmony, start, end, bar):
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1) leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1)
history_cost=repetition(heard(notes,'develop'),incoming.history,bar) history_cost=repetition(heard(notes,'develop'),incoming.history,bar)
return 2*checked.exposure+entry+leaps+.35*max(0,4-density,density-6)+.8*history_cost return 2*checked.exposure+entry+leaps+.35*max(0,4-density,density-6)+.8*history_cost
@functools.lru_cache(maxsize=512)
def defining_members(symbol):
"""Written chord identity, without inventing omitted thirds or sevenths."""
chord=generator.harmony.parse_symbol(symbol)
degrees=generator.harmony.degrees(chord)
root=generator.model.NATURAL[chord['root']['letter']]+generator.model.alteration(chord['root']['alter'])
selected={d for d in (3,6,7) if d in degrees}
if chord['kind'] in ('suspended-second','suspended-fourth'):
selected.update(d for d in (2,4) if d in degrees)
if degrees.get(5,0):selected.add(5)
return frozenset(int(root+generator.harmony.degree_semitones(d,degrees[d]))%12 for d in selected)
def destination_cost(pitch, start, end, harmony, incoming=None, goal=None, terminal=False):
"""Shared bounded function/trajectory cost at retrieval and final selection."""
span=harmony.at(start)
if not span or not span.scale:return float('inf')
number=pitch.number(); pc=number%12
if pc not in harmony.members(start,end):return float('inf')
tonic=generator.model.parse_pitch_name(span.scale.root)
tonic_pc=int(generator.model.NATURAL[tonic['letter']]+generator.model.alteration(tonic['alter']))%12
function=0. if pc in defining_members(span.chord) or terminal and pc==tonic_pc else .25
register=min(.15,abs(number-goal)/80) if goal is not None else 0.
entry=min(.15,max(0,abs(number-incoming.last.pitch.number())-7)/80) if incoming and incoming.last else 0.
previous=next((h.ending[0] for h in reversed(incoming.history) if h.ending),None) if incoming else None
recurrence=.04 if previous==pc else 0.
following=next((s for s in harmony.spans if s.start>=end and s.scale and s.roles.basic),None)
lookahead=0.
if following:
future=[p for p in range(max(41,number-12),min(76,number+12)+1) if p%12 in following.roles.basic and p%12 in following.scale.pitches]
if future:lookahead=.04*min(1,min(abs(p-number) for p in future)/5)
# Contour comes from the heard theme, not a universal upward/nearest rule.
direction=0.
if incoming and incoming.home and len(incoming.home.notes)>1 and incoming.last:
motion=incoming.home.notes[-1].pitch.number()-incoming.home.notes[0].pitch.number()
direction=.025 if (number-incoming.last.pitch.number())*motion<0 else 0.
return function+register+entry+recurrence+lookahead+direction
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)
+5 -1
View File
@@ -48,7 +48,11 @@ class Policy:
settling_target: int = 6 settling_target: int = 6
return_target: int = 2 return_target: int = 2
history_bars: int = 16 history_bars: int = 16
composition_version: str = 'thematic-phrasing-2' enclosure_targets: int = 4
enclosure_routes: int = 4
enclosure_frontier: int = 8
enclosure_max_per_32_bars: int = 4
composition_version: str = 'chord-destinations-enclosures-1'
# Preserve the former default root seeds while decoupling them from policy edits. # Preserve the former default root seeds while decoupling them from policy edits.
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1' SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
+1 -1
View File
@@ -510,7 +510,7 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
except ValueError: except ValueError:
return None return None
complete = plan.end == (plan.passage_end or plan.end) complete = plan.end == (plan.passage_end or plan.end)
checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete) checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete, enclosures=draft.enclosures)
valid, _ = generator.solo.harmonic.anchors_valid(draft.anchors, canonical, plan.end, complete=complete, closure=plan.closure) 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): if not checked.valid or not valid or not target_ok(notes, plan.target):
return None return None
+10 -5
View File
@@ -42,6 +42,8 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
ref=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(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)) 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: 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}') 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: for transform in draft.transformations:
@@ -72,17 +74,20 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
root = generator.scales.selection.root_number(choice.span.chord) 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] 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' 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 enclosure 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}') 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}')
lines.append('\nSounding harmonic spans (ties merged before classification):') lines.append('\nSounding harmonic spans (ties merged before classification):')
for event in detail['harmonic_roles']: for event in detail['harmonic_roles']:
if float(fractions.Fraction(event['sounding_duration'])) >= 1 or event['protected']: if float(fractions.Fraction(event['sounding_duration'])) >= 1 or event['protected'] or event['enclosure']:
lines.append(' ' + json.dumps(event, sort_keys=True)) lines.append(' ' + json.dumps(event, sort_keys=True))
return '\n'.join(lines) + '\n' return '\n'.join(lines) + '\n'
def structure(solo): def structure(solo):
harmony = generator.solo.harmonic.Harmony(solo.choices) 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)) contracts = tuple(dict.fromkeys(t for p in solo.phrases if p.state for t in p.state.treatments))
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True) enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures)
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures)
def rational(value): def rational(value):
if isinstance(value, dict): if isinstance(value, dict):
@@ -101,12 +106,12 @@ def structure(solo):
anchors = [] anchors = []
for anchor in phrase.anchors: for anchor in phrase.anchors:
event = generator.solo.harmonic.anchor_note(anchor, notes) event = generator.solo.harmonic.anchor_note(anchor, notes)
anchors.append({**rational(dataclasses.asdict(anchor)), 'pitch': anchor.pitch.label(), 'completed': event is not None, 'actual_start': str(event.start) if event else None, 'actual_end': str(event.end) if event else None}) 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 () 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])} 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: 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.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)) if draft} 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]) entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes])
if phrase.initial and phrase.refined: if phrase.initial and phrase.refined:
# A source hold may already be sounding at this generated phrase boundary. # A source hold may already be sounding at this generated phrase boundary.
@@ -119,7 +124,7 @@ def structure(solo):
entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home) entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home)
entry['score'] = phrase.score entry['score'] = phrase.score
phrases.append(entry) phrases.append(entry)
return {'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, 'harmonic_roles': harmony.describe(solo.notes(), contracts), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])} return {'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, 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures), '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): def phrasing_report(solo):
+16 -7
View File
@@ -162,9 +162,12 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
lambda x:(len(x[0]),tuple(b-a for a,b in x[0][:3]),x[0][0][0]),policy.DEFAULT.theme_rhythm_alternatives) 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): def pitches(harmony,note,base,*,members=False,limit=6,incoming=None):
from generator.solo.leads import available from generator.solo.leads import available
domain=available(harmony,note.start,note.end,members=members or note.end-note.start>=2) 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()), 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) lambda p:((p.number()-base>0)-(p.number()-base<0),p.number()//12),limit)
@@ -245,7 +248,7 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
# History is evaluated by the caller after retrieving these concrete options. # History is evaluated by the caller after retrieving these concrete options.
# Every state component used to construct this pool is in its cache key. # Every state component used to construct this pool is in its cache key.
if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',harmony.choices) if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',harmony.choices)
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,home,operation,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT) key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,incoming.history,home,operation,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
if key in context.results: if key in context.results:
context.diagnostics['cache_hits']+=1;return context.results[key] context.diagnostics['cache_hits']+=1;return context.results[key]
result=[]; count=len(ref.notes) result=[]; count=len(ref.notes)
@@ -256,19 +259,24 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
'theme',event_id=f'{ref.id}:{start}:{i}') for i,(a,b) in enumerate(pattern)) 'theme',event_id=f'{ref.id}:{start}:{i}') for i,(a,b) in enumerate(pattern))
base=incoming.last.pitch.number() if incoming.last else ref.notes[0].pitch.number() base=incoming.last.pitch.number() if incoming.last else ref.notes[0].pitch.number()
firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3) firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3)
targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,members=True,limit=policy.DEFAULT.theme_destinations) targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,members=True,limit=policy.DEFAULT.theme_destinations,incoming=incoming)
for first in firsts: for first in firsts:
for target in targets: for target in targets:
# Rank joint jobs, retaining rhythm and destination coverage before # Rank joint jobs, retaining rhythm and destination coverage before
# expensive path instantiation. The budget is spent on complete routes. # expensive path instantiation. The budget is spent on complete routes.
rank=max(0,abs(first.number()-base)-5)/12+abs(target.number()-(goal if goal is not None else base))/48 rank=max(0,abs(first.number()-base)-5)/12+phrasing.destination_cost(target,templates[-1].start,templates[-1].end,harmony,incoming,goal if goal is not None else base,terminal)
jobs.append((rank,templates,label,first,target)) jobs.append((rank,templates,label,first,target))
jobs.sort(key=lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4])))) jobs.sort(key=lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4]))))
selected=[];rhythm_seen=set();target_seen=set();entry_seen=set() selected=[];rhythm_seen=set();target_seen=set();entry_seen=set();degrees_seen=set()
for job in jobs:
family=phrasing.destination_family(job[4],job[1][-1].start,harmony)
if family not in degrees_seen:
selected.append(job);degrees_seen.add(family)
for job in jobs: for job in jobs:
rhythm=tuple((n.start,n.end) for n in job[1]);target=job[4].number();entry=job[3].number() rhythm=tuple((n.start,n.end) for n in job[1]);target=job[4].number();entry=job[3].number()
if rhythm not in rhythm_seen or target not in target_seen or entry not in entry_seen: if rhythm not in rhythm_seen or target not in target_seen or entry not in entry_seen:
selected.append(job);rhythm_seen.add(rhythm);target_seen.add(target);entry_seen.add(entry) if job not in selected:selected.append(job)
rhythm_seen.add(rhythm);target_seen.add(target);entry_seen.add(entry)
job_limit=policy.DEFAULT.theme_route_jobs*(batch+1) job_limit=policy.DEFAULT.theme_route_jobs*(batch+1)
for job in jobs: for job in jobs:
if len(selected)>=job_limit:break if len(selected)>=job_limit:break
@@ -301,7 +309,7 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(actual,actual[1:]))/max(1,len(actual)-1) leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(actual,actual[1:]))/max(1,len(actual)-1)
direction=min(1,abs(last.pitch.number()-goal)/12) if goal is not None else 0. direction=min(1,abs(last.pitch.number()-goal)/12) if goal is not None else 0.
# Activity belongs to the complete phrase; contracts are pass/fail. # Activity belongs to the complete phrase; contracts are pass/fail.
score=2*checked.exposure+entry+leaps+.35*direction score=2*checked.exposure+entry+leaps+phrasing.destination_cost(last.pitch,last.start,last.end,harmony,incoming,goal,terminal)
anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76), anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76),
last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end) last.end-last.start,'intermediate',harmony.at(last.start).chord,hold_until=last.end)
result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections)) result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections))
@@ -445,6 +453,7 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits) phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
if phrase is None:continue if phrase is None:continue
score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar) score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar)
score+=sum(phrasing.destination_cost(u.anchor.pitch,u.anchor.earliest,u.anchor.hold_until,harmony,incoming,phrasing.register_goal(incoming,part,u.notes[0].pitch.number()),u.anchor.role=='terminal') for u in units)/len(units)
appearance_state=incoming appearance_state=incoming
for unit in units: for unit in units:
score+=phrasing.appearance_cost(unit.operation,appearance_state,unit.notes[0].start,solo_end)/len(units) score+=phrasing.appearance_cost(unit.operation,appearance_state,unit.notes[0].start,solo_end)/len(units)
+17
View File
@@ -229,6 +229,21 @@ class RouteCandidate:
relationship: str relationship: str
sources: tuple[str, ...] sources: tuple[str, ...]
@dataclasses.dataclass(frozen=True)
class EnclosureContract:
"""A complete, timed upper/lower approach to a previously chosen arrival.
These snapshots describe sounding events, independent of notation splits or
guitar placement. Only the lower event may receive a scale exception.
"""
id: str
upper: Note
lower: Note
target: Note
chord: str
target_role: str
protected_tail: SustainedTail
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class MelodicDraft: class MelodicDraft:
id: str id: str
@@ -252,6 +267,7 @@ class MelodicDraft:
source_links: tuple[tuple[str, tuple[str, ...]], ...] = () source_links: tuple[tuple[str, tuple[str, ...]], ...] = ()
source_reference: SourceReference | None = None source_reference: SourceReference | None = None
sustained_tails: tuple[SustainedTail, ...] = () sustained_tails: tuple[SustainedTail, ...] = ()
enclosures: tuple[EnclosureContract, ...] = ()
def rests(self): def rests(self):
cursor, result = self.start, [] cursor, result = self.start, []
for note in self.notes: for note in self.notes:
@@ -326,6 +342,7 @@ class Phrase:
initial: MelodicDraft | None = None initial: MelodicDraft | None = None
refined: MelodicDraft | None = None refined: MelodicDraft | None = None
routes: tuple[RouteCandidate, ...] = () routes: tuple[RouteCandidate, ...] = ()
before_enclosure: MelodicDraft | None = None
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Solo: class Solo:
+1 -1
View File
@@ -183,7 +183,7 @@ class DraftCompositionTests(unittest.TestCase):
self.assertIsNotNone(draft) self.assertIsNotNone(draft)
self.assertEqual((draft.start, draft.end), (phrase.plan.start, phrase.plan.end)) self.assertEqual((draft.start, draft.end), (phrase.plan.start, phrase.plan.end))
prefix = (dataclasses.replace(previous, position=None),) if previous else () prefix = (dataclasses.replace(previous, position=None),) if previous else ()
self.assertTrue(harmony.evaluate((*prefix, *draft.notes), phrase.state.treatments, until=draft.end).valid) self.assertTrue(harmony.evaluate((*prefix, *draft.notes), phrase.state.treatments, until=draft.end, enclosures=draft.enclosures).valid)
self.assertTrue(generator.solo.placement.place(draft.notes, self.solo.tempo, previous)) self.assertTrue(generator.solo.placement.place(draft.notes, self.solo.tempo, previous))
self.assertTrue(draft.transformations) self.assertTrue(draft.transformations)
previous = phrase.state.last previous = phrase.state.last
+242
View File
@@ -0,0 +1,242 @@
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_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_only_removes_post_selection_ornaments(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)
self.assertEqual(enclosures.strict(decorated).notes(),strict.notes())
self.assertEqual([p.state.home for p in decorated.phrases],[p.state.home for p in strict.phrases])
self.assertEqual([p.anchors for p in decorated.phrases],[p.anchors for p in strict.phrases])
self.assertTrue(harmonic.Harmony(strict.choices).evaluate(strict.notes()).valid)
self.assertTrue(all(not p.draft.enclosures for p in strict.phrases))
self.assertTrue(any(p.draft.enclosures for p in decorated.phrases))
if __name__=='__main__':unittest.main()