Soloing
This commit is contained in:
@@ -85,7 +85,7 @@ def backing(baseline, foreground, part, rhythms, references, choices, initial=No
|
||||
generator.model.validate_document(document)
|
||||
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."""
|
||||
analysis = generator.solo.analysis.analyze(source)
|
||||
sections = regions(source)
|
||||
@@ -105,7 +105,7 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
|
||||
if sounding:
|
||||
cutoff -= generator.model.fraction(pickup['duration']) - min(sounding)
|
||||
requests[part] = (template, cutoff, initial)
|
||||
selected = generator.solo.composer.compose_pair(analysis, requests, scales=scales, sequences=sequences, rhythms=rhythms, contexts=contexts, stage=stage)
|
||||
selected = generator.solo.composer.compose_pair(analysis, requests, scales=scales, sequences=sequences, rhythms=rhythms, contexts=contexts, stage=stage,enclosures_enabled=enclosures_enabled)
|
||||
for solo, section_index, other in zip(selected, (3, 4), ('electric', 'acoustic')):
|
||||
part = solo.part
|
||||
first, count, offset, length = sections[section_index]
|
||||
|
||||
@@ -78,13 +78,13 @@ def fingerprint():
|
||||
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')))
|
||||
|
||||
def capture(root, stage, libraries=None, contexts=None):
|
||||
def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=True):
|
||||
destination = root / stage
|
||||
if destination.exists():
|
||||
raise ValueError(f'{destination}: snapshot already exists; choose a new output root')
|
||||
rhythms, scales, sequences = libraries or resources()
|
||||
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:
|
||||
result['policy'] = importlib.import_module('generator.solo.policy').describe()
|
||||
except ModuleNotFoundError:
|
||||
@@ -94,7 +94,7 @@ def capture(root, stage, libraries=None, contexts=None):
|
||||
checkpoint = root / f'.{stage}_{path.stem}_checkpoint.json'
|
||||
if checkpoint.exists():
|
||||
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.setdefault('resumed_songs', []).append(path.stem)
|
||||
continue
|
||||
@@ -107,7 +107,7 @@ def capture(root, stage, libraries=None, contexts=None):
|
||||
if shared:
|
||||
for context in shared.values():
|
||||
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}
|
||||
regions = generator.solo.arrangement.regions(document)
|
||||
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})
|
||||
song['solos'][solo.part]['draft_documents'] = drafts
|
||||
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):
|
||||
continue
|
||||
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)
|
||||
result['songs'][path.stem] = song
|
||||
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 / '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)
|
||||
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']})
|
||||
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', {}):
|
||||
plain = excerpt(solo['lead_stages']['refined']['document'], offset, count)
|
||||
plain_prefix = prefix.with_name(prefix.name + '_plain')
|
||||
@@ -328,13 +341,17 @@ def write_passage_index(root, snapshots):
|
||||
for stage in ('before', 'after'):
|
||||
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>")
|
||||
strict_available='before_enclosure' 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'):
|
||||
body.append(f'<tr><th>{window}</th>')
|
||||
for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')):
|
||||
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>')
|
||||
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:
|
||||
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>')
|
||||
@@ -352,13 +369,14 @@ def main():
|
||||
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('--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()
|
||||
if args.compare_phrases:
|
||||
libraries, contexts = resources(), {}
|
||||
capture(args.output, 'phrase', libraries, contexts)
|
||||
capture(args.output, 'revised', libraries, contexts)
|
||||
elif args.capture:
|
||||
capture(args.output, args.capture)
|
||||
capture(args.output, args.capture,enclosures_enabled=not args.no_enclosures)
|
||||
else:
|
||||
render(args.output)
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import time
|
||||
|
||||
import generator.solo.enclosures
|
||||
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
@@ -15,7 +18,8 @@ import generator.solo.policy
|
||||
import generator.solo.realization
|
||||
import generator.solo.types
|
||||
|
||||
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated', mode='thematic'):
|
||||
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)
|
||||
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
|
||||
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()
|
||||
options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode)
|
||||
result = options[0][1]
|
||||
result.diagnostics['strict_composition_seconds'] = time.monotonic()-started
|
||||
result = generator.solo.enclosures.refine(result,sequences,enclosures_enabled and stage=='elaborated' and mode=='thematic')
|
||||
validate(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."""
|
||||
from generator.solo import leads
|
||||
started = time.monotonic()
|
||||
pools = {}
|
||||
for part, (measures,end,initial) in requests.items():
|
||||
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')
|
||||
_,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)
|
||||
strict_seconds=time.monotonic()-started
|
||||
selected=[]
|
||||
for solo in (acoustic,electric):
|
||||
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)
|
||||
return acoustic,electric
|
||||
selected.append(solo)
|
||||
return tuple(selected)
|
||||
|
||||
|
||||
def phrase_notes(phrase):
|
||||
@@ -120,8 +132,6 @@ def validate(solo):
|
||||
for note in solo.notes():
|
||||
if note.start < 0 or note.end > solo.end or note.end <= note.start or previous and previous.end > note.start:
|
||||
raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
|
||||
if note.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())):
|
||||
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
|
||||
previous = note
|
||||
@@ -131,7 +141,8 @@ def validate(solo):
|
||||
raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized')
|
||||
harmony = generator.solo.harmonic.Harmony(solo.choices)
|
||||
contracts = tuple(dict.fromkeys(t for phrase in solo.phrases if phrase.state for t in phrase.state.treatments))
|
||||
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:
|
||||
raise ValueError(f'{solo.part} solo: {checked.error}')
|
||||
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):
|
||||
return 'neighbor' if a == c else 'passing' if (b - a) * (c - b) > 0 else ''
|
||||
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)
|
||||
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 = [], []
|
||||
for treatment in treatments:
|
||||
if treatment.start >= until:
|
||||
@@ -130,11 +186,13 @@ class Harmony:
|
||||
covered = note.start
|
||||
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)
|
||||
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')
|
||||
covered = end
|
||||
function = self.function(number, span)
|
||||
if function == 'basic' or note.role == 'quote':
|
||||
if function == 'basic' or note.role == 'quote' or enclosure:
|
||||
continue
|
||||
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')
|
||||
@@ -144,13 +202,16 @@ class Harmony:
|
||||
if covered < note.end:
|
||||
return Result(False, 0, error=f'{note.pitch.label()} at {covered}: missing harmonic coverage')
|
||||
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)
|
||||
ornaments, error = self.enclosure_events(notes,enclosures) if enclosures else ({}, '')
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
result = []
|
||||
for index, note in enumerate(notes):
|
||||
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]
|
||||
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
|
||||
|
||||
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,
|
||||
hold_until=arrival+hold)
|
||||
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))
|
||||
selected=sequences.diverse(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)
|
||||
selected=destination_options(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
|
||||
lambda x:x[1].pitch,lambda x:x[1].earliest,harmony,limit)
|
||||
return tuple(a for _,a in selected)
|
||||
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
return_target: int = 2
|
||||
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.
|
||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||
|
||||
@@ -510,7 +510,7 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
|
||||
except ValueError:
|
||||
return None
|
||||
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)
|
||||
if not checked.valid or not valid or not target_ok(notes, plan.target):
|
||||
return None
|
||||
|
||||
@@ -42,6 +42,8 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
|
||||
ref=draft.source_reference
|
||||
lines.append(f' Source influence {ref.id}: {ref.treatment}; source {ref.source_id}, beats {ref.start}-{ref.end}. Later developments refer to solo theme {phrase.state.home.id}.')
|
||||
lines.append(' Retained contracts: '+', '.join(f'{f.id}: {f.preserves}' for f in draft.features))
|
||||
for enclosure in draft.enclosures:
|
||||
lines.append(f' Enclosure {enclosure.id}: {enclosure.upper.pitch.label()} → {enclosure.lower.pitch.label()} → {enclosure.target.pitch.label()}; {enclosure.target_role} over {enclosure.chord}; arrival {enclosure.target.start}; protected through {enclosure.target.end}')
|
||||
for route in phrase.routes:
|
||||
lines.append(f' Complete route {route.id}: {route.notes[0].start}-{route.destination.start}; destination {route.destination.pitch.label()}; {route.relationship}; sources {route.sources}')
|
||||
for transform in draft.transformations:
|
||||
@@ -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)
|
||||
degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12]
|
||||
function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection'
|
||||
if note.pitch.number()%12 not in generator.solo.types.permitted(solo.choices,note.start,note.end):
|
||||
function='validated 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('\nSounding harmonic spans (ties merged before classification):')
|
||||
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))
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
def structure(solo):
|
||||
harmony = generator.solo.harmonic.Harmony(solo.choices)
|
||||
contracts = tuple(dict.fromkeys(t for p in solo.phrases if p.state for t in p.state.treatments))
|
||||
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):
|
||||
if isinstance(value, dict):
|
||||
@@ -101,12 +106,12 @@ def structure(solo):
|
||||
anchors = []
|
||||
for anchor in phrase.anchors:
|
||||
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 ()
|
||||
entry = {'id': phrase.plan.id, 'passage': phrase.plan.passage_id, 'start': str(phrase.plan.start), 'end': str(phrase.plan.end), 'ending': phrase.plan.ending, 'closure': phrase.plan.closure, 'ending_completed': valid, 'ending_error': reason, 'anchors': anchors, 'motif_lineage': rational([dataclasses.asdict(m) for m in motifs])}
|
||||
if phrase.draft:
|
||||
entry.update({'draft': rational(dataclasses.asdict(phrase.draft)), 'draft_rests': rational(phrase.draft.rests()), 'edits': rational([dataclasses.asdict(e) for e in phrase.edits]), 'relationships': generator.solo.drafts.relationship_features(phrase.draft, tuple(n for d in phrase.decisions for n in d.notes)), 'home': phrase.state.home.id if phrase.state and phrase.state.home else None, 'current_development': phrase.state.development.id if phrase.state and phrase.state.development else None})
|
||||
entry['lead_stages'] = {name: {'id': draft.id, 'attacks': len(draft.notes), 'notes': rational([dataclasses.asdict(n) for n in draft.notes])} for name, draft in (('initial', phrase.initial), ('refined', phrase.refined)) 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])
|
||||
if phrase.initial and phrase.refined:
|
||||
# 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['score'] = phrase.score
|
||||
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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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
|
||||
domain=available(harmony,note.start,note.end,members=members or note.end-note.start>=2)
|
||||
if members:
|
||||
return phrasing.destination_options(domain,lambda p:(phrasing.destination_cost(p,note.start,note.end,harmony,incoming,base),p.number()),
|
||||
lambda p:p,lambda p:note.start,harmony,limit)
|
||||
return sequences.diverse(domain,lambda p:(abs(p.number()-base),p.number()),
|
||||
lambda p:((p.number()-base>0)-(p.number()-base<0),p.number()//12),limit)
|
||||
|
||||
@@ -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.
|
||||
# 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)
|
||||
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:
|
||||
context.diagnostics['cache_hits']+=1;return context.results[key]
|
||||
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))
|
||||
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)
|
||||
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 target in targets:
|
||||
# Rank joint jobs, retaining rhythm and destination coverage before
|
||||
# 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.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:
|
||||
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:
|
||||
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)
|
||||
for job in jobs:
|
||||
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)
|
||||
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.
|
||||
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),
|
||||
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))
|
||||
@@ -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)
|
||||
if phrase is None:continue
|
||||
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
|
||||
for unit in 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
|
||||
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)
|
||||
class MelodicDraft:
|
||||
id: str
|
||||
@@ -252,6 +267,7 @@ class MelodicDraft:
|
||||
source_links: tuple[tuple[str, tuple[str, ...]], ...] = ()
|
||||
source_reference: SourceReference | None = None
|
||||
sustained_tails: tuple[SustainedTail, ...] = ()
|
||||
enclosures: tuple[EnclosureContract, ...] = ()
|
||||
def rests(self):
|
||||
cursor, result = self.start, []
|
||||
for note in self.notes:
|
||||
@@ -326,6 +342,7 @@ class Phrase:
|
||||
initial: MelodicDraft | None = None
|
||||
refined: MelodicDraft | None = None
|
||||
routes: tuple[RouteCandidate, ...] = ()
|
||||
before_enclosure: MelodicDraft | None = None
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Solo:
|
||||
|
||||
Reference in New Issue
Block a user