Soloing
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user