Cleanup
This commit is contained in:
@@ -1,431 +0,0 @@
|
||||
"""Build-only snapshots and like-for-like listening comparisons; no engine presets."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import array
|
||||
import collections
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import hashlib
|
||||
import html
|
||||
import importlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
import wave
|
||||
|
||||
import generator.arrange.generate
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.musicxml_writer
|
||||
import generator.parts
|
||||
import generator.playback
|
||||
import generator.rhythm
|
||||
import generator.scales.library
|
||||
import generator.solo.arrangement
|
||||
import generator.solo.report
|
||||
import generator.solo.sequences
|
||||
import generator.solo.realization
|
||||
import generator.solo.references
|
||||
import generator.solo.harmonic
|
||||
|
||||
STAGES = ('baseline', 'A', 'B', 'C', 'D')
|
||||
|
||||
def metrics(solo):
|
||||
notes = solo.notes()
|
||||
gaps, cursor, run, runs = [], fractions.Fraction(), 0, []
|
||||
for note in notes:
|
||||
if note.start > cursor:
|
||||
gaps.append((cursor, note.start))
|
||||
if note.end - note.start == fractions.Fraction(1, 2):
|
||||
run = run + 1 if note.start == cursor else 1
|
||||
else:
|
||||
run = 0
|
||||
runs.append(run)
|
||||
cursor = note.end
|
||||
if cursor < solo.end:
|
||||
gaps.append((cursor, solo.end))
|
||||
planned = [(p.start, p.end) for phrase in solo.phrases for p in getattr(phrase.plan, 'passages', ()) if p.intent == 'space']
|
||||
planned.extend((p.plan.end - p.plan.release, p.plan.end) for p in solo.phrases if p.plan.release)
|
||||
planned.extend((c.span.start, c.span.end) for c in solo.choices if c.scale is None)
|
||||
unplanned = []
|
||||
for start, end in gaps:
|
||||
fragments = [(start, end)]
|
||||
for a, b in planned:
|
||||
fragments = [(x, y) for low, high in fragments for x, y in ((low, min(high, a)), (max(low, b), high)) if y > x]
|
||||
unplanned.extend(b - a for a, b in fragments)
|
||||
quoted = sum(n.end - n.start for n in notes if n.role == 'quote')
|
||||
return {
|
||||
'phrases': len(solo.phrases), 'attacks': len(notes),
|
||||
'attacks_per_four_beats': float(4 * len(notes) / solo.end),
|
||||
'sounding_occupancy': float(sum(n.end - n.start for n in notes) / solo.end),
|
||||
'longest_unplanned_gap_beats': float(max(unplanned, default=0)),
|
||||
'longest_connected_eighth_run': max(runs, default=0),
|
||||
'quotation_coverage': float(quoted / solo.end),
|
||||
'treatments': dict(collections.Counter(d.treatment for p in solo.phrases for d in p.decisions)),
|
||||
'search': getattr(solo, 'diagnostics', {}),
|
||||
}
|
||||
|
||||
def fingerprint():
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(pathlib.Path('generator').rglob('*.py')):
|
||||
digest.update(path.as_posix().encode())
|
||||
digest.update(path.read_bytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
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, *, 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('audition/controls'))
|
||||
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:
|
||||
result['policy'] = {'baseline': True}
|
||||
for path in generator.parts.source_paths(pathlib.Path('data/songs')):
|
||||
source_hash = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
checkpoint = root / f'.{stage}_{path.stem}_checkpoint.json'
|
||||
if checkpoint.exists():
|
||||
saved = json.loads(checkpoint.read_text())
|
||||
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
|
||||
started = time.monotonic()
|
||||
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
|
||||
plans = generator.arrange.generate.plan_arrangement(document, rhythms)
|
||||
parts = {part: generator.arrange.generate._arrange_document(document, part, plans) for part in generator.parts.PART_TYPES}
|
||||
solos = []
|
||||
shared = contexts.setdefault(path.stem, {p: generator.solo.realization.SearchContext() for p in ('acoustic', 'electric')}) if contexts is not None else 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,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)):
|
||||
first, count, offset, length = regions[index]
|
||||
song['solos'][solo.part] = {'first': first, 'count': count, 'offset': str(offset), 'length': str(length), 'tempo': str(solo.tempo), 'metrics': metrics(solo), 'report': generator.solo.report.describe(solo, scales), 'structure': generator.solo.report.structure(solo)}
|
||||
drafts = []
|
||||
template = parts[solo.part]['staves'][0]['measures'][first:first + count]
|
||||
boundaries = [fractions.Fraction()]
|
||||
for measure in template:
|
||||
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
|
||||
for phrase in solo.phrases:
|
||||
if not phrase.draft:
|
||||
continue
|
||||
begin = max(i for i,boundary in enumerate(boundaries) if boundary <= phrase.plan.start)
|
||||
stop = next(i for i, boundary in enumerate(boundaries) if boundary >= phrase.plan.end)
|
||||
view = excerpt(parts[solo.part], first + begin, stop - begin)
|
||||
local = tuple(dataclasses.replace(n, start=n.start - boundaries[begin], end=n.end - boundaries[begin], continuation=False if i == 0 else n.continuation) for i, n in enumerate(phrase.draft.notes))
|
||||
placed = generator.solo.placement.place(local, solo.tempo, profile=solo.profile)
|
||||
if placed is None:
|
||||
raise ValueError(f'{phrase.plan.id}: draft cannot be placed for independent audition')
|
||||
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], 'draft_' + phrase.plan.id)
|
||||
generator.solo.notation.replace_region(view, 0, foreground)
|
||||
generator.model.validate_document(view)
|
||||
generator.fretboard.validate_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
|
||||
stages = {}
|
||||
for stage_name in ('initial', 'refined', 'before_rhythm', '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)
|
||||
placed = generator.solo.placement.place(notes, solo.tempo, profile=solo.profile if stage_name != 'initial' else None)
|
||||
if placed is None:
|
||||
raise ValueError(stage_name + ': selected theme cannot be placed')
|
||||
sounded = generator.solo.types.sounding_notes(placed[1])
|
||||
contracts = tuple(dict.fromkeys(t for p in solo.phrases for t in p.state.treatments))
|
||||
checked = generator.solo.harmonic.Harmony(solo.choices).evaluate(sounded, contracts, until=solo.end, complete=True)
|
||||
if not checked.valid and stage_name != 'initial':
|
||||
raise ValueError(stage_name + ': ' + checked.error)
|
||||
view = excerpt(parts[solo.part], first, count)
|
||||
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], stage_name + '_' + solo.part)
|
||||
generator.solo.notation.replace_region(view, 0, foreground)
|
||||
generator.model.validate_document(view)
|
||||
generator.fretboard.validate_document(view)
|
||||
stages[stage_name] = {'document': view, 'attacks': len(sounded), 'occupancy': float(sum(n.end - n.start for n in sounded) / solo.end), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error}
|
||||
song['solos'][solo.part]['lead_stages'] = stages
|
||||
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({'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')
|
||||
|
||||
def excerpt(document, first, count):
|
||||
result = copy.deepcopy(document)
|
||||
staff = result['staves'][0]
|
||||
staff['measures'] = staff['measures'][first:first + count]
|
||||
notes = [n for m in staff['measures'] for v in m['voices'] for n in v['notes']]
|
||||
identities = {p['id'] for n in notes for p in n['pitches']}
|
||||
events = {n['id'] for n in notes}
|
||||
for note in notes:
|
||||
for pitch in note['pitches']:
|
||||
for key in ('tie_from', 'tie_to'):
|
||||
if pitch.get(key) not in identities:
|
||||
pitch.pop(key, None)
|
||||
staff['tuplets'] = [t for t in staff.get('tuplets', []) if t['start'] in events and t['end'] in events]
|
||||
tuplets = {t['id'] for t in staff['tuplets']}
|
||||
for note in notes:
|
||||
if 'tuplet_ids' in note:
|
||||
note['tuplet_ids'] = [i for i in note['tuplet_ids'] if i in tuplets]
|
||||
offset = sum(generator.model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first])
|
||||
end = offset + sum(generator.model.fraction(m['duration']) for m in staff['measures'])
|
||||
for key in ('scale_spans', 'octave_shifts'):
|
||||
staff[key] = [{**s, 'start': generator.model.rational(max(offset, generator.model.fraction(s['start'])) - offset), 'end': generator.model.rational(min(end, generator.model.fraction(s['end'])) - offset)} for s in staff.get(key, []) if generator.model.fraction(s['start']) < end and generator.model.fraction(s['end']) > offset]
|
||||
generator.model.validate_document(result)
|
||||
return result
|
||||
|
||||
def mix(documents, tempo, root, cache):
|
||||
timelines = [generator.playback.compile_timeline(document) for document in documents]
|
||||
rate = generator.playback.SAMPLE_RATE
|
||||
factor = fractions.Fraction(60 * rate, tempo)
|
||||
frames = round(max(generator.model.fraction(t['duration']) for t in timelines) * factor)
|
||||
output = array.array('f', [0]) * (frames + 2400)
|
||||
for index, timeline in enumerate(timelines):
|
||||
gain = 1.0 if index == 0 else 0.55
|
||||
for event in timeline['events']:
|
||||
pitch = generator.model.fraction(event['pitch'])
|
||||
if pitch not in cache:
|
||||
sample = generator.playback.generate_tone(root, pitch)
|
||||
with wave.open(str(root / sample['path'])) as audio:
|
||||
values = array.array('h', audio.readframes(audio.getnframes()))
|
||||
if sys.byteorder != 'little':
|
||||
values.byteswap()
|
||||
cache[pitch] = sample, values
|
||||
sample, values = cache[pitch]
|
||||
start = round(generator.model.fraction(event['onset']) * factor)
|
||||
end = round((generator.model.fraction(event['onset']) + generator.model.fraction(event['duration'])) * factor)
|
||||
for frame in range(start, min(len(output), end + 2400)):
|
||||
age = frame - start
|
||||
position = age if age < sample['loop_end'] else sample['loop_start'] + (age - sample['loop_start']) % (sample['loop_end'] - sample['loop_start'])
|
||||
release = 1 if frame < end else (1 - (frame - end) / 2399) ** 2
|
||||
output[frame] += gain * release * values[position] / 32767
|
||||
return output
|
||||
|
||||
def register_control(baseline,song,part):
|
||||
"""Whole-phrase octave transfers of the preserved baseline, never new tunes."""
|
||||
from generator.solo import profiles,types,placement,notation
|
||||
detail=song['solos'][part]
|
||||
raw=detail['structure'].get('instrument_profile')
|
||||
if not raw:return None
|
||||
profile=profiles.Instrument(raw['part'],tuple(raw['strings']),raw['reference'],tuple(raw['melody_range']))
|
||||
result=[]
|
||||
for phrase in baseline['solos'][part]['structure']['phrases']:
|
||||
notes=[]
|
||||
for n in phrase['draft']['notes']:
|
||||
p=n['pitch']
|
||||
notes.append(types.Note(fractions.Fraction(n['start']),fractions.Fraction(n['end']),
|
||||
types.Pitch(p['letter'],fractions.Fraction(p['alter']),p['octave']),tuple(n['sources']),n['role'],
|
||||
continuation=n['continuation'],event_id=n['event_id']))
|
||||
options=[]
|
||||
for octave in range(-2,3):
|
||||
moved=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=n.pitch.octave+octave)) for n in notes)
|
||||
if not all(profile.permits(n.pitch.number()) for n in moved):continue
|
||||
placed=placement.place(moved,fractions.Fraction(detail['tempo']),result[-1] if result else None,profile)
|
||||
if placed:
|
||||
rank=profile.cost(profiles.weighted_center(moved))+.02*abs(octave)+.001*placed[0]/max(1,len(moved))
|
||||
options.append((rank,octave,placed[1]))
|
||||
if not options:raise ValueError(part+': no whole-phrase register control within the instrument limits')
|
||||
result.extend(min(options,key=lambda x:x[:2])[2])
|
||||
view=excerpt(song['parts'][part],detail['first'],detail['count'])
|
||||
notation.replace_region(view,0,notation.measures(view['staves'][0]['measures'],types.sounding_notes(result),'register_control_'+part))
|
||||
generator.model.validate_document(view)
|
||||
generator.fretboard.validate_document(view)
|
||||
return view
|
||||
|
||||
|
||||
def render(root):
|
||||
stages = ('baseline', 'phrase', 'revised') if (root / 'revised' / 'snapshot.json').exists() else ('before', 'after') if (root / 'before' / 'snapshot.json').exists() else STAGES
|
||||
snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in stages}
|
||||
cache, clips, manifest = {}, [], {'sample_rate': generator.playback.SAMPLE_RATE, 'stages': {}, 'clips': []}
|
||||
for stage, snapshot in snapshots.items():
|
||||
manifest['stages'][stage] = {k: v for k, v in snapshot.items() if k != 'songs'}
|
||||
for song_name, song in snapshot['songs'].items():
|
||||
baseline = snapshots[stages[0]]['songs'][song_name]
|
||||
comparison = snapshots[stages[-1]]['songs'][song_name]
|
||||
if song['source_sha256'] != baseline['source_sha256']:
|
||||
raise ValueError(f'{song_name}: source changed between audition stages')
|
||||
for part, solo in song['solos'].items():
|
||||
other = 'electric' if part == 'acoustic' else 'acoustic'
|
||||
windows = [('full', 0, solo['count']), ('opening8', 0, min(8, solo['count'])), ('closing4', max(0, solo['count'] - 4), min(4, solo['count']))] if stages[0] == 'before' or stages[-1] == 'revised' else [('', 0, min(8, solo['count']))]
|
||||
for label, offset, count in windows:
|
||||
first = solo['first'] + offset
|
||||
foreground = excerpt(song['parts'][part], first, count)
|
||||
prefix = root / stage / (f'{song_name}_{part}' + ('_' + label if label else ''))
|
||||
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), foreground)
|
||||
prefix.with_suffix('.txt').write_text(solo['report'])
|
||||
if 'structure' in solo:
|
||||
prefix.with_suffix('.json').write_text(json.dumps(solo['structure'], indent=2) + '\n')
|
||||
for responsive in [False, True] if stage == stages[-1] else [False]:
|
||||
backing = excerpt((song if responsive else comparison)['parts'][other], first, count)
|
||||
vocal = excerpt(baseline['parts']['vocal'], first, count)
|
||||
path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav')
|
||||
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(comparison['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')
|
||||
generator.musicxml_writer.write_musicxml(plain_prefix.with_suffix('.musicxml'), plain)
|
||||
backing = excerpt(comparison['parts'][other], first, count)
|
||||
vocal = excerpt(baseline['parts']['vocal'], first, count)
|
||||
path = plain_prefix.with_name(plain_prefix.name + '_fixed').with_suffix('.wav')
|
||||
clips.append((path, mix((plain, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'plain_theme', 'window': label})
|
||||
if label == 'full':
|
||||
for kind, document in (('plain', plain), ('elaborated', foreground)):
|
||||
path = prefix.with_name(prefix.name + '_' + kind + '_solo').with_suffix('.wav')
|
||||
clips.append((path, mix((document,), fractions.Fraction(solo['tempo']), root, cache)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': kind + '_unaccompanied', 'window': label})
|
||||
if stage == 'after':
|
||||
for control, document in (('register_control', register_control(baseline,song,part)),
|
||||
('before_rhythm',solo.get('lead_stages',{}).get('before_rhythm',{}).get('document'))):
|
||||
if document is None: continue
|
||||
prefix=root/stage/f'{song_name}_{part}_{control}'
|
||||
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'),document)
|
||||
backing=excerpt(comparison['parts'][other],solo['first'],solo['count'])
|
||||
path=prefix.with_suffix('.wav')
|
||||
clips.append((path,mix((document,backing),fractions.Fraction(solo['tempo']),root,cache)))
|
||||
manifest['clips'].append({'path':path.relative_to(root).as_posix(),'kind':control,'window':'full'})
|
||||
for draft in solo.get('draft_documents', ()):
|
||||
prefix = root / stage / f'{song_name}_{part}_draft_{draft["id"]}'
|
||||
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), draft['document'])
|
||||
if draft['first'] < 8:
|
||||
backing = excerpt(comparison['parts'][other], solo['first'] + draft['first'], draft['count'])
|
||||
path = prefix.with_suffix('.wav')
|
||||
clips.append((path, mix((draft['document'], backing), fractions.Fraction(solo['tempo']), root, cache)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'simple_phrase_draft', 'draft_id': draft['draft_id'], 'first': draft['first'], 'count': draft['count']})
|
||||
if stage in ('phrase', 'revised'):
|
||||
neutral = excerpt(song['parts'][part], solo['first'], min(8, solo['count']))
|
||||
for measure in neutral['staves'][0]['measures']:
|
||||
for voice in measure['voices']:
|
||||
for event in voice['notes']:
|
||||
for pitch in event['pitches']:
|
||||
pitch.update({'letter': 'c', 'alter': generator.model.rational(0), 'octave': 5, 'tablature': {'string': 2, 'fret': 1}})
|
||||
path = root / stage / f'{song_name}_{part}_rhythm_only.wav'
|
||||
clips.append((path, mix((neutral,), fractions.Fraction(solo['tempo']), root, cache)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'rhythm_only_diagnostic', 'valid_harmonic_solo': False})
|
||||
# One common attenuation preserves relative loudness and identical backing.
|
||||
peak = max(abs(value) for _, samples in clips for value in samples)
|
||||
gain = min(1, 0.95 / peak) if peak else 1
|
||||
manifest['shared_gain'] = gain
|
||||
for path, samples in clips:
|
||||
generator.playback.write_pcm(path, (value * gain for value in samples))
|
||||
(root / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
write_phrase_index(root, snapshots) if stages[-1] == 'revised' else write_passage_index(root, snapshots) if stages[0] == 'before' else write_index(root, snapshots)
|
||||
print(f'Rendered {len(clips)} comparisons under {root}', flush=True)
|
||||
|
||||
def write_phrase_index(root, snapshots):
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Melodic phrase comparisons</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin-bottom:2rem}td,th{text-align:left;padding:.7rem;border-bottom:1px solid #ccd1c9}audio{width:250px}small{display:block}</style><h1>Melodic phrase comparisons</h1><p>Compare the current baseline, complete phrase construction, and phrase construction with two-sided join revision. Tempo, scale policy, activity targets and seed inputs are unchanged. Fixed mixes use baseline accompaniment and one shared gain. Revised mixes also include the normal responsive backing.</p><p>Draft pools are shared when incoming musical states match; earlier edits can legitimately change later incoming states. Pool fingerprints are recorded in the reports. Simple drafts are auditioned individually, with a held entrance restarted at the excerpt boundary. Rhythm-only clips use a neutral pitch and are not valid harmonic solos.</p><p>Listen for one recognizable idea developing toward a prepared destination. Metrics do not establish musical improvement. <a href="manifest.json">Manifest and diagnostics</a>.</p>']
|
||||
for name, song in snapshots['revised']['songs'].items():
|
||||
for part in song['solos']:
|
||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2>')
|
||||
body.append('<table><tr><th>Excerpt</th><th>Baseline</th><th>Phrase construction</th><th>With join revision</th></tr>')
|
||||
for window in ('opening8', 'full', 'closing4'):
|
||||
body.append(f'<tr><th>{window}</th>')
|
||||
for stage in ('baseline', 'phrase', 'revised'):
|
||||
prefix = f'{stage}/{name}_{part}_{window}'
|
||||
body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a> · <a href="{prefix}.json">Structure</a></small></td>')
|
||||
body.append('</tr>')
|
||||
body.append('</table>')
|
||||
for stage in ('phrase', 'revised'):
|
||||
detail = snapshots[stage]['songs'][name]['solos'][part]
|
||||
metric = detail['metrics']
|
||||
body.append(f'<p>{stage}: {metric["attacks_per_four_beats"]:.2f} attacks / four beats; {metric["sounding_occupancy"]:.1%} occupancy. Rhythm only: <audio controls preload="none" src="{stage}/{name}_{part}_rhythm_only.wav"></audio></p>')
|
||||
for draft in detail.get('draft_documents', ()):
|
||||
if draft['first'] < 8:
|
||||
prefix = f'{stage}/{name}_{part}_draft_{draft["id"]}'
|
||||
body.append(f'<p>Simple draft {draft["id"]}: <audio controls preload="none" src="{prefix}.wav"></audio> <a href="{prefix}.musicxml">MusicXML</a></p>')
|
||||
body.append(f'<p>Complete solo with responsive backing: <audio controls preload="none" src="revised/{name}_{part}_full_responsive.wav"></audio></p>')
|
||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||
|
||||
def write_index(root, snapshots):
|
||||
labels = {'baseline': 'Original engine', 'A': 'Scoring repair', 'B': 'Activity shapes', 'C': 'Motifs and retrieval', 'D': 'Phrase lookahead'}
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Solo comparisons</title><style>body{font:16px system-ui;max-width:1200px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:1rem;border-bottom:1px solid #ccd1c9}audio{width:280px}small{display:block;margin-top:.5rem}</style><h1>Solo comparisons</h1><p>Eight bars from the same solo entrance at the source tempo. All stages use the original backing and one shared mix gain. D also includes the normal backing that responds to the new solo.</p><p>A: scoring and register. B: activity and breathing. C: transformation contracts, contextual motifs and diverse paths. D: one-phrase lookahead. Expressive playback is deferred.</p><p>Metrics below describe the complete solo, not just the excerpt. These are diagnostics; listening and playing determine musical quality. <a href="manifest.json">Build fingerprints and diagnostics</a>.</p>']
|
||||
for name, song in snapshots['D']['songs'].items():
|
||||
for part in song['solos']:
|
||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2><table><tr><th>Stage</th><th>Same backing</th><th>Complete solo</th></tr>')
|
||||
for stage in STAGES:
|
||||
prefix = stage + '/' + name + '_' + part
|
||||
metrics = snapshots[stage]['songs'][name]['solos'][part]['metrics']
|
||||
stats = f"{metrics['attacks_per_four_beats']:.2f} attacks / four beats<br>{metrics['sounding_occupancy']:.0%} sounding occupancy<br>{metrics['longest_connected_eighth_run']} connected eighths<br>{metrics['longest_unplanned_gap_beats']:g}-beat longest unplanned gap"
|
||||
body.append(f'<tr><td>{stage}: {labels[stage]}<small><a href="{prefix}.musicxml">Notation</a> · <a href="{prefix}.txt">Phrase report</a></small></td><td><audio controls preload="none" src="{prefix}_fixed.wav"></audio></td><td>{stats}</td></tr>')
|
||||
body.append(f'</table><p>D with responsive backing: <audio controls preload="none" src="D/{name}_{part}_responsive.wav"></audio></p>')
|
||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||
|
||||
def write_passage_index(root, snapshots):
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Connected solo passages</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:.75rem;border-bottom:1px solid #ccd1c9}audio{width:260px}small{display:block;margin-top:.5rem}</style><h1>Connected solo passages</h1><p>Before and after use the same source songs, scale-selection settings, activity targets, tempo and root seed inputs. All fixed comparisons use the final canonical backing. The published arrangement reuses that same learned accompaniment throughout the form. The returning vocal pickup and one shared mix gain are preserved.</p><p>Listen for a convincing settlement on the current chord, an idea developing across several bars, intermediate arrivals that continue, and a prepared ending. Metrics alone do not establish musical improvement. <a href="manifest.json">Fingerprints and diagnostics</a>.</p>']
|
||||
for name, song in snapshots['after']['songs'].items():
|
||||
for part in song['solos']:
|
||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2>')
|
||||
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>")
|
||||
for control,label in (('register_control','Original phrases with register/string changes only'),('before_rhythm','Selected strict theme before rhythmic revision')):
|
||||
if control=='before_rhythm' and control not in song['solos'][part].get('lead_stages',{}):continue
|
||||
prefix=f'after/{name}_{part}_{control}'
|
||||
body.append(f'<p>{label}: <audio controls preload="none" src="{prefix}.wav"></audio> <a href="{prefix}.musicxml">Notation</a></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>Final canonical arrangement</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>')
|
||||
body.append('</tr>')
|
||||
body.append('</table>')
|
||||
if plain_available:
|
||||
prefix=f'after/{name}_{part}_full'
|
||||
body.append(f'<p>Unaccompanied plain theme: <audio controls preload="none" src="{prefix}_plain_solo.wav"></audio> Elaborated: <audio controls preload="none" src="{prefix}_elaborated_solo.wav"></audio></p>')
|
||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_auditions'))
|
||||
action = parser.add_mutually_exclusive_group(required=True)
|
||||
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,enclosures_enabled=not args.no_enclosures)
|
||||
else:
|
||||
render(args.output)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -63,17 +63,22 @@ def validate(notes,contracts,harmony):
|
||||
return {},c.id+': missing or changed expression event/target'
|
||||
if c.preparation and not same(c.preparation):return {},c.id+': changed preparation'
|
||||
if any(n.event_id in approved for n in c.events):return {},c.id+': overlapping expression permissions'
|
||||
target=c.target; span=harmony.at(c.boundary if c.kind=='anticipation' else target.start)
|
||||
target=c.target; span=harmony.at(c.boundary if c.boundary is not None else target.start)
|
||||
if not span or not span.scale or span.chord!=c.chord:return {},c.id+': wrong target harmony'
|
||||
if c.kind in ('approach','enclosure','connection'):
|
||||
rule=next((r for r in vocabulary.data('lead_ins')['patterns'] if r['id']==c.rule and r['kind']==c.kind),None)
|
||||
if not rule or len(c.events)!=len(rule['semitones']):return {},c.id+': unknown approach formula'
|
||||
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,target.start,target.end):return {},c.id+': invalid destination hold'
|
||||
arrival=c.boundary if c.boundary is not None else target.start
|
||||
if c.boundary is not None and not any(a.kind=='anticipation' and a.target==target and a.boundary==c.boundary for a in contracts):return {},c.id+': future target lacks anticipation contract'
|
||||
if target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,arrival,target.end):return {},c.id+': invalid destination hold'
|
||||
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())
|
||||
if target.start-cursor>F(vocabulary.data('lead_ins')['max_approach']):return {},c.id+': approach too long'
|
||||
for n,step,duration in zip(c.events,rule['semitones'],rule['durations']):
|
||||
if n.start!=cursor or n.end!=cursor+F(duration) or n.pitch.number()!=target.pitch.number()+step:return {},c.id+': wrong register, interval, or timing'
|
||||
cursor=n.end
|
||||
for index in rule.get('mapped_indices',[]):
|
||||
n=c.events[index]
|
||||
if n.pitch.number()%12 not in types.permitted(harmony.choices,n.start,n.end):return {},c.id+': declared diatonic neighbor is outside the mapped collection'
|
||||
if any(n.start<c.target.start and n.end>c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):return {},c.id+': interrupted approach'
|
||||
if c.kind=='connection' and (not c.preparation or c.preparation.end!=c.events[0].start or abs(c.preparation.pitch.number()-c.events[0].pitch.number())>2):return {},c.id+': chromatic connection lacks its incoming motion'
|
||||
elif c.kind=='color':
|
||||
@@ -88,7 +93,7 @@ def validate(notes,contracts,harmony):
|
||||
if not rule['outside'] and n.pitch.number()%12 not in region.scale.pitches:return {},c.id+': color requires mapped collection'
|
||||
last=c.events[-1]
|
||||
if rule.get('resolve'):
|
||||
if target is last or not 0<=target.start-last.end<=F(vocabulary.data('harmony')['resolution_gap']) or not 0<abs(target.pitch.number()-last.pitch.number())<=2 or target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': unresolved color'
|
||||
if target.event_id==last.event_id or not 0<=target.start-last.end<=F(vocabulary.data('harmony')['resolution_gap']) or not 0<abs(target.pitch.number()-last.pitch.number())<=2 or target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': unresolved color'
|
||||
elif c.kind=='suspension':
|
||||
rule=next((r for r in vocabulary.data('harmony').get('suspensions',[]) if r['id']==c.rule),None)
|
||||
if not rule or len(c.events)!=1 or c.boundary is None:return {},c.id+': unsupported suspension'
|
||||
@@ -232,7 +237,7 @@ def joint_candidates(phrases,incoming,harmony,tempo,context,*,bar=F(4)):
|
||||
counts['rejected_by_rule']+=1;continue
|
||||
if not drafts.contract_valid(phrase.draft,actual)[0]:
|
||||
counts['rejected_by_rule']+=1;continue
|
||||
checked=harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(c,))
|
||||
checked=harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(*phrase.draft.expressions,c))
|
||||
if not checked.valid:
|
||||
counts['rejected_by_rule']+=1;continue
|
||||
counts['valid']+=1
|
||||
@@ -249,7 +254,7 @@ def joint_candidates(phrases,incoming,harmony,tempo,context,*,bar=F(4)):
|
||||
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end))
|
||||
for t in phrase.draft.transformations)
|
||||
changed=dataclasses.replace(phrase.draft,id=phrase.draft.id+':'+c.rule,
|
||||
notes=actual,expressions=(c,),transformations=transforms)
|
||||
notes=actual,expressions=(*phrase.draft.expressions,c),transformations=transforms)
|
||||
candidate=realization.checked_phrase(phrase.plan,changed,actual,incoming.treatments,incoming,harmony,tempo,phrase.edits)
|
||||
if candidate is None:continue
|
||||
trace[c.kind]['retained']+=1
|
||||
@@ -304,7 +309,7 @@ def refine(solo,enabled=True):
|
||||
events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
|
||||
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end))
|
||||
for t in draft.transformations)
|
||||
changed=dataclasses.replace(draft,notes=actual,expressions=(c,),transformations=transforms)
|
||||
changed=dataclasses.replace(draft,notes=actual,expressions=(*phrase.draft.expressions,c),transformations=transforms)
|
||||
routes=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
|
||||
options.append((cost,dataclasses.replace(phrase,draft=changed,decisions=decisions,routes=routes),c))
|
||||
_,chosen,c=min(options,key=lambda v:(round(v[0],8),policy.stable_rank(seed,(phrase.plan.id,v[2]))))
|
||||
|
||||
@@ -16,6 +16,7 @@ def join_variants(draft,harmony,library):
|
||||
"""
|
||||
from generator.solo import routes, policy
|
||||
fixed=set(draft.protected)|set(draft.structural)|{e for f in draft.features for e in f.events}
|
||||
fixed.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
|
||||
produced=0
|
||||
for join in (t.start for t in draft.transformations[1:]):
|
||||
inside=tuple(n for n in draft.notes if join-2<=n.start and n.end<=join+2)
|
||||
@@ -34,7 +35,7 @@ def join_variants(draft,harmony,library):
|
||||
for route in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'two-sided join'):
|
||||
if route.notes==inside:continue
|
||||
actual=tuple(n for n in draft.notes if n.end<=inside[0].start)+route.notes+tuple(n for n in draft.notes if n.start>=inside[-1].end)
|
||||
if not drafts.contract_valid(draft,actual)[0] or not harmony.evaluate(actual,until=draft.end,complete=True).valid:continue
|
||||
if not drafts.contract_valid(draft,actual)[0] or not harmony.evaluate(actual,until=draft.end,complete=True,expressions=draft.expressions).valid:continue
|
||||
transforms=tuple(dataclasses.replace(t,events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
|
||||
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end)) for t in draft.transformations)
|
||||
changed=dataclasses.replace(draft,id=draft.id+':join:'+route.id,notes=actual,transformations=transforms)
|
||||
@@ -61,6 +62,7 @@ def shared_arrival(draft,harmony,treatments=()):
|
||||
joins={t.start for t in draft.transformations[1:] if t.operation=='continue'}
|
||||
fixed=set(draft.protected)|set(draft.structural)
|
||||
fixed.update(n.event_id for n in notes if n.role=='quote')
|
||||
fixed.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
|
||||
fixed.update(e for f in draft.features if 'rhythm' in f.preserves for e in f.events)
|
||||
edits=[]
|
||||
for join in sorted(joins):
|
||||
@@ -69,7 +71,7 @@ def shared_arrival(draft,harmony,treatments=()):
|
||||
if a.event_id in fixed or b.event_id in fixed:continue
|
||||
actual=(*notes[:i],dataclasses.replace(b,start=a.start),*notes[i+2:])
|
||||
if not drafts.contract_valid(draft,actual)[0]:continue
|
||||
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True).valid:continue
|
||||
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True,expressions=draft.expressions).valid:continue
|
||||
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]:continue
|
||||
edits.append(types.DraftEdit('shared_arrival',a.start,b.end,(a.event_id,b.event_id),(b.event_id,),
|
||||
'reopen provisional endpoint as the same sounding continuation; preserve feature and arrival contracts'))
|
||||
@@ -92,6 +94,7 @@ def revise(draft, harmony, treatments=()):
|
||||
if len(notes)<3: return draft, tuple(edits)
|
||||
protected=set(draft.protected)|set(draft.structural)|{n.event_id for n in notes if n.role=='quote'}
|
||||
# An identifying rhythm also protects its internal rests.
|
||||
protected.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
|
||||
protected.update(e for feature in draft.features if 'rhythm' in feature.preserves for e in feature.events)
|
||||
for _ in range(2):
|
||||
proposals=[]
|
||||
@@ -111,7 +114,7 @@ def revise(draft, harmony, treatments=()):
|
||||
if not groups.valid(tuple((n.start-draft.start,n.end-draft.start) for n in actual)):continue
|
||||
if fragmentation(actual)>=fragmentation(notes): continue
|
||||
if not drafts.contract_valid(draft,actual)[0]: continue
|
||||
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True).valid: continue
|
||||
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True,expressions=draft.expressions).valid: continue
|
||||
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]: continue
|
||||
valid.append(actual)
|
||||
if not valid: break
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Joint rhythmic and harmonic actions, constructed before pitch-route search.
|
||||
|
||||
Rules describe musical relationships, never reference-score phrases. Contracts
|
||||
are bound to the selected whole route and validated before a candidate survives.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary, harmony as chords
|
||||
from generator.solo import types, expressions, groups, sequences, policy
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Proposal:
|
||||
notes: tuple
|
||||
contracts: tuple
|
||||
purpose: str
|
||||
|
||||
def bind(self, notes):
|
||||
actual={n.event_id:n for n in notes}
|
||||
return tuple(dataclasses.replace(c,events=tuple(actual[n.event_id] for n in c.events),
|
||||
target=actual[c.target.event_id],preparation=actual[c.preparation.event_id] if c.preparation else None) for c in self.contracts)
|
||||
|
||||
|
||||
def replace(notes, start, end, events):
|
||||
# Provisional rhythm is editable, but a sliced tuplet is not an explanation.
|
||||
before=tuple(dataclasses.replace(n,end=start) if n.end>start else n for n in notes if n.start<start)
|
||||
after=tuple(dataclasses.replace(n,start=end,event_id=n.event_id+':resumed') if n.start<end else n for n in notes if n.end>end)
|
||||
actual=(*before,*events,*after)
|
||||
if any(n.end<=n.start for n in actual):return ()
|
||||
origin=notes[0].start
|
||||
if not groups.valid(tuple((n.start-origin,n.end-origin) for n in actual)):return ()
|
||||
return actual
|
||||
|
||||
|
||||
def proposals(notes,harmony,start,finish,ending=None):
|
||||
"""Plan future arrivals, colors and approaches together with exact rhythm."""
|
||||
rules=vocabulary.data('melodic_vocabulary')['gesture_rules']
|
||||
fixed_end=ending.earliest if ending else finish
|
||||
for rule in rules:
|
||||
if rule['kind']=='harmonic_arrival':
|
||||
for span in harmony.spans:
|
||||
boundary=span.start
|
||||
prior=harmony.at(boundary-F(1,100))
|
||||
if not span.scale or not prior or not prior.scale or prior.chord==span.chord:continue
|
||||
if not start+1<boundary<fixed_end:continue
|
||||
for offset in map(F,rule['arrival_offsets']):
|
||||
arrival=boundary+offset
|
||||
if arrival<start+1:continue
|
||||
for hold in map(F,rule['holds']):
|
||||
end=arrival+hold
|
||||
if end>min(fixed_end,span.end) or end<=boundary:continue
|
||||
breath=min(F(rule['release']),fixed_end-end)
|
||||
near=next((n.pitch.number() for n in notes if n.start<=arrival<n.end),notes[-1].pitch.number())
|
||||
members=harmony.members(boundary if offset<0 else arrival,end)&types.permitted(harmony.choices,boundary if offset<0 else arrival,end)
|
||||
candidates=tuple(types.scale_pitch(span.scale,i) for i in range(-21,29))
|
||||
candidates=tuple(p for p in candidates if p.number()%12 in members and harmony.profile.permits(p.number()))
|
||||
# Third/seventh, root/fifth and register alternatives remain real
|
||||
# competitors; no target name or song position determines a winner.
|
||||
targets=sequences.diverse(candidates,lambda p:(abs(p.number()-near),p.number()),
|
||||
lambda p:next((d for pc,d in span.roles.degrees if pc==p.number()%12),()),3)
|
||||
for pitch in targets:
|
||||
target=types.Note(arrival,end,pitch,notes[0].sources,'arrival',event_id=notes[0].event_id+f':{rule["id"]}:{arrival}:{pitch.number()}')
|
||||
actual=replace(notes,arrival,end+breath,(target,))
|
||||
if len(actual)<2:continue
|
||||
contracts=()
|
||||
if offset<0:
|
||||
contracts=(types.ExpressionContract(target.event_id,'anticipation','early_chord_tone',(target,),target,span.chord,boundary=boundary),)
|
||||
yield Proposal(actual,contracts,rule['id']+':'+str(offset))
|
||||
elif rule['kind']=='sustained_color':
|
||||
for i,n in enumerate(notes):
|
||||
if n.start>=fixed_end or n.end-n.start<1:continue
|
||||
span=harmony.at(n.start)
|
||||
if not span or not span.scale:continue
|
||||
for color in vocabulary.data('harmony')['colors']:
|
||||
if n.end-n.start>F(color['max_beats']) or not expressions.eligible_color(color,span,harmony):continue
|
||||
pitch=expressions.degree_pitch(chords.parse_symbol(span.chord),color['degree'],color['alter'],n.pitch.number())
|
||||
if not harmony.profile.permits(pitch.number()) or pitch==n.pitch:continue
|
||||
changed=dataclasses.replace(n,pitch=pitch,role='color')
|
||||
actual=(*notes[:i],changed,*notes[i+1:]);target=changed
|
||||
if color.get('resolve'):
|
||||
if i+1==len(notes):continue
|
||||
next_note=notes[i+1];future=harmony.at(next_note.start)
|
||||
if not future or not future.scale:continue
|
||||
targets=[types.scale_pitch(future.scale,j) for j in range(-21,29)]
|
||||
targets=[p for p in targets if 0<abs(p.number()-pitch.number())<=2 and p.number()%12 in harmony.members(next_note.start,next_note.end) and harmony.profile.permits(p.number())]
|
||||
if not targets:continue
|
||||
target=dataclasses.replace(next_note,pitch=min(targets,key=lambda p:(abs(p.number()-next_note.pitch.number()),p.number())))
|
||||
actual=(*notes[:i],changed,target,*notes[i+2:])
|
||||
c=types.ExpressionContract(changed.event_id+':'+color['id'],'color',color['id'],(changed,),target,span.chord)
|
||||
yield Proposal(actual,(c,),rule['id'])
|
||||
|
||||
|
||||
def approach(proposal,harmony,start,ending=None):
|
||||
"""An approach may share its target with an anticipated destination."""
|
||||
for i,target in enumerate(proposal.notes):
|
||||
if target.end-target.start<1:continue
|
||||
anticipated=next((c for c in proposal.contracts if c.kind=='anticipation' and c.target.event_id==target.event_id),None)
|
||||
time=anticipated.boundary if anticipated else target.start
|
||||
if target.pitch.number()%12 not in harmony.members(time,target.end):continue
|
||||
span=harmony.at(time)
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
arrival=target.start;cursor=arrival-F(rule['gap'])-sum(map(F,rule['durations']),F())
|
||||
if cursor<start+F(1,2):continue
|
||||
if ending and cursor>=ending.earliest:continue
|
||||
events=[]
|
||||
for j,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])):
|
||||
events.append(types.Note(cursor,cursor+F(duration),expressions.chromatic(target.pitch,step),target.sources,rule['kind'],event_id=target.event_id+':'+rule['id']+':'+str(j)))
|
||||
cursor+=F(duration)
|
||||
actual=replace(proposal.notes,events[0].start,target.start,tuple(events))
|
||||
if not actual:continue
|
||||
if any(n.event_id not in {a.event_id for a in actual} for c in proposal.contracts for n in (*c.events,c.target)):continue
|
||||
prior=next((n for n in reversed(actual) if n.end<=events[0].start),None)
|
||||
c=types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(events),target,span.chord,
|
||||
boundary=anticipated.boundary if anticipated else None,preparation=prior if rule['kind']=='connection' else None)
|
||||
yield Proposal(actual,(*proposal.contracts,c),proposal.purpose+' + '+rule['kind'])
|
||||
|
||||
|
||||
def select(notes,harmony,start,finish,ending=None,limit=4):
|
||||
# Provenance differs across candidate histories; musical work does not.
|
||||
# This cache belongs to one immutable Harmony and includes all timing,
|
||||
# pitches, roles, continuation flags, profile and ending constraints.
|
||||
canonical=tuple(dataclasses.replace(n,sources=(),event_id=f'@{i}@') for i,n in enumerate(notes))
|
||||
key=(canonical,start,finish,ending,limit,harmony.profile)
|
||||
if not hasattr(harmony,'gesture_cache'):harmony.gesture_cache={}
|
||||
if key not in harmony.gesture_cache:
|
||||
harmony.gesture_cache[key]=_select(canonical,harmony,start,finish,ending,limit)
|
||||
if len(harmony.gesture_cache)>4096:harmony.gesture_cache.pop(next(iter(harmony.gesture_cache)))
|
||||
def identity(value):
|
||||
for i,n in enumerate(notes):value=value.replace(f'@{i}@',n.event_id)
|
||||
return value
|
||||
def restore(n):
|
||||
i=int(n.event_id.split('@')[1])
|
||||
return dataclasses.replace(n,event_id=identity(n.event_id),sources=notes[i].sources)
|
||||
return tuple(Proposal(tuple(map(restore,p.notes)),tuple(dataclasses.replace(c,id=identity(c.id),
|
||||
events=tuple(map(restore,c.events)),target=restore(c.target),
|
||||
preparation=restore(c.preparation) if c.preparation else None) for c in p.contracts),p.purpose)
|
||||
for p in harmony.gesture_cache[key])
|
||||
|
||||
|
||||
def _select(notes,harmony,start,finish,ending=None,limit=4):
|
||||
plain=Proposal(notes,(),'direct')
|
||||
base=sequences.diverse(proposals(notes,harmony,start,finish,ending),
|
||||
lambda p:(abs(len(p.notes)-len(notes)),tuple((n.start,n.end,n.pitch.number()) for n in p.notes)),
|
||||
lambda p:(p.purpose,tuple(c.kind for c in p.contracts)),8)
|
||||
values=(*base,*approach(plain,harmony,start,ending),*(p for b in base if b.purpose.startswith('harmonic') for p in approach(b,harmony,start,ending)))
|
||||
# Complete structural choices compete, not one replacement at each attack.
|
||||
return sequences.diverse(values,lambda p:(abs(len(p.notes)-len(notes)),policy.stable_rank('joint-purpose',tuple((n.start,n.end,n.pitch) for n in p.notes))),
|
||||
lambda p:tuple(c.kind for c in p.contracts) or (p.purpose,),limit)
|
||||
@@ -6,6 +6,7 @@ not a rhythmic boundary and must not be used to clip a group.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
|
||||
|
||||
@@ -48,6 +49,7 @@ def describe(pattern, *, origin=F(), source='', imported=False):
|
||||
return tuple(result)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def valid(pattern):
|
||||
try:describe(pattern);return True
|
||||
except ValueError:return False
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Audition selected lead stages and accepted controls through the normal output path."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import html
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.musicxml_writer
|
||||
import generator.parts
|
||||
import generator.playback
|
||||
import generator.scales.timeline
|
||||
import generator.scales.selection
|
||||
import generator.solo.audition as audition
|
||||
import generator.solo.drafts as drafts
|
||||
import generator.solo.harmonic as harmonic
|
||||
import generator.solo.notation as notation
|
||||
import generator.solo.placement as placement
|
||||
import generator.solo.realization as realization
|
||||
import generator.solo.references as references
|
||||
import generator.solo.types as types
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
def document_for(template, notes, tempo, identifier):
|
||||
placed = placement.place(notes, tempo)
|
||||
if placed is None:
|
||||
raise ValueError(identifier + ': no valid guitar placement')
|
||||
document = copy.deepcopy(template)
|
||||
notation.replace_region(document, 0, notation.measures(document['staves'][0]['measures'], placed[1], identifier))
|
||||
generator.model.validate_document(document)
|
||||
generator.fretboard.validate_document(document)
|
||||
timeline = generator.playback.compile_timeline(document)
|
||||
expected = [(n.start, n.end - n.start, n.pitch.number()) for n in types.sounding_notes(placed[1])]
|
||||
actual = [(generator.model.fraction(e['onset']), generator.model.fraction(e['duration']), generator.model.fraction(e['pitch'])) for e in timeline['events']]
|
||||
if actual != expected:
|
||||
raise ValueError(identifier + ': notation/playback changed the sounding melody')
|
||||
return document
|
||||
|
||||
def controls(song, libraries):
|
||||
"""Literal reference injection is restricted to this diagnostic, never production."""
|
||||
_, scales, sequences = libraries
|
||||
reference, = references.load_library(pathlib.Path('audition/controls'))
|
||||
solo = song['solos']['acoustic']
|
||||
measures = song['parts']['acoustic']['staves'][0]['measures'][solo['first']:solo['first'] + solo['count']]
|
||||
choices = generator.scales.selection.select(generator.scales.timeline.read({'measures': measures}), generator.scales.timeline.Solo(F(), F(solo['length']), 'control'), scales)
|
||||
harmony = harmonic.Harmony(choices)
|
||||
template = audition.excerpt(song['parts']['acoustic'], solo['first'], 8)
|
||||
result = []
|
||||
for label, source in (('plain', reference.notes), ('runs', reference.decorated)):
|
||||
checked = harmony.evaluate(source, until=reference.duration, complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError('accepted control: ' + checked.error)
|
||||
theme = types.Motif(reference.id, source, reference.duration, tuple(c for _, c in reference.harmony), 'accepted control')
|
||||
groups = tuple(types.Transformation(f'control_{i}', reference.id, 'control_0', 'statement' if i == 0 else 'answer', F(i * 16), F((i + 1) * 16), tuple(n.event_id for n in source if i * 16 <= n.start < (i + 1) * 16), tuple((n.start - i * 16, n.end - i * 16) for n in source if i * 16 <= n.start < (i + 1) * 16), preserves=(), source=reference.id) for i in range(2))
|
||||
final = source[-1]
|
||||
terminal = types.Anchor('accepted_terminal', final.start, final.start, final.pitch, frozenset({final.pitch.number() % 12}), (55, 79), final.end - final.start, 'terminal', 'Dm', hold_until=final.end)
|
||||
home = realization.make_identity('control_0', reference.id, source[:len(groups[0].events)], F(), F(16), 'statement', harmony)
|
||||
draft = types.MelodicDraft('accepted_' + label, F(), reference.duration, source, groups, (final.event_id,), (), (terminal,), home, 'statement', (F(16),), references.identifying_features(source, reference.id), reference.id, theme)
|
||||
plan = types.PhrasePlan('accepted_' + label, F(), reference.duration, theme, 'statement', 'opening_reference', None, (55, 79), F(9, 8), F(1), passage_id='control', passage_start=F(), passage_end=reference.duration)
|
||||
incoming = types.ContinuationState('')
|
||||
phrase = realization.checked_phrase(plan, draft, source, (), incoming, harmony, F(solo['tempo']))
|
||||
if phrase is None:
|
||||
raise ValueError('accepted control rejected by phrase validation')
|
||||
context = realization.SearchContext()
|
||||
refined = realization.refine(phrase, (phrase,), incoming, harmony, sequences, F(solo['tempo']), context)
|
||||
elaborated = realization.elaborate(refined, incoming, harmony, sequences, F(solo['tempo']), context)
|
||||
for stage, notes in (('injected', source), ('processed', realization.emitted_notes(elaborated))):
|
||||
identifier = 'accepted_' + label + '_' + stage
|
||||
checked = harmony.evaluate(notes, until=reference.duration, complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError(identifier + ': ' + checked.error)
|
||||
result.append({'id': identifier, 'document': document_for(template, notes, F(solo['tempo']), identifier), 'attacks': len(notes), 'harmonic_exposure': checked.exposure, 'changed_events': sum((a.start, a.end, a.pitch) != (b.start, b.end, b.pitch) for a, b in zip(source, notes)) + abs(len(source) - len(notes)), 'edits': [e.kind for e in elaborated.edits] if stage == 'processed' else [], 'acceptance': reference.acceptance, 'reference_version': reference.version})
|
||||
return result
|
||||
|
||||
def render(root):
|
||||
baseline = json.loads((root / 'paraphrase_baseline/snapshot.json').read_text())
|
||||
current = json.loads((root / 'elaborated/snapshot.json').read_text())
|
||||
cache, clips = {}, []
|
||||
gain = 0.4 # Shared audition gain; every mixed clip is checked for clipping.
|
||||
manifest = {'implementation': current['implementation'], 'baseline_implementation': baseline['implementation'], 'policy': current['policy'], 'shared_gain': gain, 'clips': clips, 'stage_semantics': 'Source is the original head, close paraphrase is the preserved previous plain theme, refined is the newly recomposed theme, and elaborated includes optional in-scale runs. The selected production branch supplies both new stages.'}
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Lead melody comparisons</title><style>body{font:16px system-ui;max-width:1500px;margin:2rem auto;padding:1rem}table{border-collapse:collapse}td,th{padding:.6rem;border-bottom:1px solid #ccc;text-align:left}audio{width:240px}small{display:block}</style><h1>Lead melody comparisons</h1><p>Compare the original head, the previous close paraphrase, the new solo theme, and its optional in-scale runs. Source controls may contain original harmonic tensions that production explicitly adapts. All fixed mixes use the original backing, identical tempo and one shared gain. Solo-only clips remove the backing. Initial/refined exports follow the selected production branches; they are not independent searches.</p><p>These metrics verify behavior, not musical quality. Listen for a recognizable tune, prepared destinations, and runs that belong to the tune. <a href="manifest.json">Diagnostics manifest</a>.</p>']
|
||||
|
||||
def clip(prefix, foreground, tempo, backing=()):
|
||||
path = root / (prefix + '.wav')
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
samples = audition.mix((foreground, *backing), tempo, root, cache)
|
||||
peak = max(map(abs, samples), default=0) * gain
|
||||
if peak >= 1:
|
||||
raise ValueError(prefix + ': shared audition gain would clip')
|
||||
generator.playback.write_pcm(path, (value * gain for value in samples))
|
||||
clips.append({'path': path.relative_to(root).as_posix(), 'tempo': str(tempo), 'peak': peak, 'attacks': len(generator.playback.compile_timeline(foreground)['events'])})
|
||||
return path.relative_to(root).as_posix()
|
||||
|
||||
for name, song in current['songs'].items():
|
||||
old = baseline['songs'][name]
|
||||
if old['source_sha256'] != song['source_sha256']:
|
||||
raise ValueError(name + ': source changed during comparison')
|
||||
for part, solo in song['solos'].items():
|
||||
other = 'electric' if part == 'acoustic' else 'acoustic'
|
||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part) + '</h2><table><tr><th>Excerpt</th><th>Song source</th><th>Previous close paraphrase</th><th>New theme</th><th>Theme with runs</th></tr>')
|
||||
for window, offset, count in (('opening8', 0, 8), ('closing4', solo['count'] - 4, 4), ('full', 0, solo['count'])):
|
||||
first = solo['first'] + offset
|
||||
backing = tuple(audition.excerpt(old['parts'][p], first, count) for p in (other, 'vocal'))
|
||||
body.append('<tr><th>' + window + '</th>')
|
||||
for stage in ('initial', 'close_paraphrase', 'refined', 'elaborated'):
|
||||
foreground = audition.excerpt(song['parts'][part], first, count) if stage=='elaborated' else audition.excerpt(old['solos'][part]['lead_stages']['refined']['document'] if stage=='close_paraphrase' else solo['lead_stages'][stage]['document'], offset, count)
|
||||
prefix = f'{stage}/{name}_{part}_{window}'
|
||||
path = clip(prefix + '_fixed', foreground, F(solo['tempo']), backing)
|
||||
solo_path = clip(prefix + '_solo', foreground, F(solo['tempo']))
|
||||
generator.musicxml_writer.write_musicxml(root / (prefix + '.musicxml'), foreground)
|
||||
body.append(f'<td><audio controls preload="none" src="{path}"></audio><small><a href="{solo_path}">Solo only</a> · <a href="{prefix}.musicxml">MusicXML</a></small></td>')
|
||||
if stage == 'elaborated':
|
||||
clip(prefix + '_responsive', foreground, F(solo['tempo']), tuple(audition.excerpt(song['parts'][p], first, count) for p in (other, 'vocal')))
|
||||
body.append('</tr>')
|
||||
body.append('</table>')
|
||||
body.append(f'<p><a href="paraphrase_baseline/{name}_{part}_full_solo.wav">Previous complete production solo</a></p>')
|
||||
prefix = f'elaborated/{name}_{part}'
|
||||
(root / (prefix + '.txt')).write_text(solo['report'])
|
||||
(root / (prefix + '.json')).write_text(json.dumps(solo['structure'], indent=2) + '\n')
|
||||
body.append(f'<p><a href="{prefix}.txt">Passage report</a> · <a href="{prefix}.json">Anchors, motif lineage, stages and routes</a> · <a href="{prefix}_full_responsive.wav">Full solo with responsive backing</a></p>')
|
||||
print('Rendered', name, part, flush=True)
|
||||
song = current['songs']['autumn_leaves']
|
||||
solo = song['solos']['acoustic']
|
||||
backing = tuple(audition.excerpt(baseline['songs']['autumn_leaves']['parts'][p], solo['first'], 8) for p in ('electric', 'vocal'))
|
||||
body.append('<h2>Accepted reference controls</h2><p>The exact accepted notes enter the ordinary validation, guitar placement, notation and playback path. Processed controls additionally run refinement and elaboration. Literal injection is confined to these controls.</p>')
|
||||
results = controls(song, audition.resources())
|
||||
manifest['controls'] = [{k: v for k, v in r.items() if k != 'document'} for r in results]
|
||||
for control in results:
|
||||
prefix = 'controls/' + control['id']
|
||||
path = clip(prefix + '_fixed', control['document'], F(solo['tempo']), backing)
|
||||
single = clip(prefix + '_solo', control['document'], F(solo['tempo']))
|
||||
generator.musicxml_writer.write_musicxml(root / (prefix + '.musicxml'), control['document'])
|
||||
body.append(f'<p>{control["id"]}: <audio controls preload="none" src="{path}"></audio> <a href="{single}">Solo only</a> · <a href="{prefix}.musicxml">MusicXML</a></p>')
|
||||
(root / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_melody_auditions'))
|
||||
action = parser.add_mutually_exclusive_group(required=True)
|
||||
action.add_argument('--capture', action='store_true')
|
||||
action.add_argument('--render', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.capture:
|
||||
audition.capture(args.output, 'elaborated')
|
||||
else:
|
||||
render(args.output)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -148,7 +148,18 @@ def appearance_cost(operation, incoming, start, end):
|
||||
return .1*min(1,max(0,float(gap)/32-1))
|
||||
|
||||
|
||||
def activity(source, ending):
|
||||
def activity(source, ending, arrivals=()):
|
||||
if arrivals:
|
||||
holds=[(n.start,n.end) for n in arrivals]
|
||||
if ending:holds.append((ending.earliest,ending.hold_until))
|
||||
points=sorted({source.start,source.end,*(t for a,b in holds for t in (a,b)),*([ending.hold_until] if ending else [])})
|
||||
result=[]
|
||||
for a,b in zip(points,points[1:]):
|
||||
held=any(x<=a and b<=y for x,y in holds)
|
||||
space=bool(ending and a>=ending.hold_until)
|
||||
result.append(types.ActivitySpan(a,b,'space' if space else 'sustained_arrival' if held else 'active',
|
||||
F() if held or space else F(5,4),F(1) if held else F()))
|
||||
return tuple(result)
|
||||
if ending is None:return (types.ActivitySpan(source.start,source.end,'active',F(5,4),F()),)
|
||||
spans=[]
|
||||
if ending.earliest>source.start:
|
||||
|
||||
@@ -50,7 +50,7 @@ class Policy:
|
||||
enclosure_routes: int = 4
|
||||
enclosure_frontier: int = 8
|
||||
enclosure_max_per_32_bars: int = 4
|
||||
composition_version: str = 'joint-gestures-1'
|
||||
composition_version: str = 'joint-arrivals-2'
|
||||
|
||||
# Preserve the former default root seeds while decoupling them from policy edits.
|
||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
|
||||
import generator.fretboard
|
||||
@@ -20,6 +21,7 @@ class Instrument:
|
||||
tuning = [p for s, p in generator.fretboard.STANDARD_TUNING if s in self.strings]
|
||||
return min(tuning) + 1, max(tuning) + 12
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def permits(self, number):
|
||||
return any(p.string_number in self.strings for p in generator.fretboard.single_positions_for_pitch(F(number)))
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ def advance(scale, previous, step):
|
||||
outgoing.reverse()
|
||||
return tuple(outgoing[abs(step) - 1:abs(step)])
|
||||
|
||||
def candidates(notes, fixed, harmony, library, register, relationship='connect theme', *, purpose='connect'):
|
||||
def candidates(notes, fixed, harmony, library, register, relationship='connect theme', *, purpose='connect', expressions=()):
|
||||
if expressions:
|
||||
return planned_candidates(notes,fixed,harmony,register,relationship,expressions)
|
||||
# Cache concrete routes only with their endpoints, timing, scale context and
|
||||
# vocabulary. The per-Harmony cache cannot leak across collection changes.
|
||||
if not hasattr(harmony,'route_cache'):
|
||||
@@ -76,6 +78,80 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
|
||||
harmony.concrete_routes[concrete_key]=selected
|
||||
return selected
|
||||
|
||||
|
||||
def planned_candidates(notes,fixed,harmony,register,relationship,contracts):
|
||||
# Cache complete contract-conditioned pitch work, not a next-note suggestion.
|
||||
# IDs/provenance are restored below; timing, pitch, role, obligations and
|
||||
# profile remain in the key. The cache is owned by this harmonic timeline.
|
||||
ids={n.event_id:str(i) for i,n in enumerate(notes)}
|
||||
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i)) for i,n in enumerate(notes))
|
||||
def note(n):return canonical[int(ids[n.event_id])]
|
||||
obligations=tuple(dataclasses.replace(c,id=str(i),events=tuple(map(note,c.events)),
|
||||
target=note(c.target),preparation=note(c.preparation) if c.preparation else None) for i,c in enumerate(contracts))
|
||||
locked=frozenset(ids[i] for i in fixed)
|
||||
key=(canonical,locked,register,harmony.profile,obligations)
|
||||
if not hasattr(harmony,'planned_route_cache'):harmony.planned_route_cache={}
|
||||
if key not in harmony.planned_route_cache:
|
||||
harmony.planned_route_cache[key]=_planned_candidates(canonical,locked,harmony,register,'',obligations)
|
||||
if len(harmony.planned_route_cache)>8192:harmony.planned_route_cache.pop(next(iter(harmony.planned_route_cache)))
|
||||
result=[]
|
||||
for route in harmony.planned_route_cache[key]:
|
||||
actual=tuple(dataclasses.replace(original,pitch=realized.pitch,position=realized.position) for original,realized in zip(notes,route.notes))
|
||||
result.append(dataclasses.replace(route,notes=actual,destination=actual[-1],relationship=relationship))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _planned_candidates(notes,fixed,harmony,register,relationship,contracts):
|
||||
"""Solve the whole route around explicit, preselected harmonic obligations.
|
||||
|
||||
Fixed expressive pitches have narrow permissions; all other attacks use the
|
||||
map. Complete contracts are checked after realization, never inferred merely
|
||||
from a label. No incomplete route is handed back to the next cell.
|
||||
"""
|
||||
from generator.solo import gestures
|
||||
proposal=gestures.Proposal(notes,contracts,relationship)
|
||||
locked={n.event_id for c in contracts for n in (*c.events,c.target)}|set(fixed)|{notes[0].event_id,notes[-1].event_id}
|
||||
special={n.event_id for c in contracts for n in c.events}
|
||||
states=[(0.,(notes[0],))]
|
||||
for i,n in enumerate(notes[1:],1):
|
||||
span=harmony.at(n.start)
|
||||
if not span or not span.scale:return ()
|
||||
allowed=types.permitted(harmony.choices,n.start,n.end)
|
||||
if n.end-n.start>=2 and n.event_id not in special:allowed &= harmony.members(n.start,n.end)
|
||||
target=next((a for a in notes[i:] if a.event_id in locked),notes[-1])
|
||||
remaining=max(1,sum(a.start<target.start for a in notes[i:]))
|
||||
following=[]
|
||||
for cost,path in states:
|
||||
prior=path[-1]
|
||||
if n.event_id in locked:options=(n.pitch,)
|
||||
else:
|
||||
options=tuple(p for step in (-3,-2,-1,1,2,3) for p in advance(span.scale,prior.pitch.number(),step))
|
||||
if prior.pitch.number()%12 in allowed:options=(*options,prior.pitch)
|
||||
for pitch in options:
|
||||
number=pitch.number()
|
||||
if not harmony.profile.permits(number) or not register[0]<=number<=register[1]:continue
|
||||
if n.event_id not in special and number%12 not in allowed:continue
|
||||
motion=number-prior.pitch.number()
|
||||
if abs(motion)>12:continue
|
||||
# Continuation aims at the known future destination. Selected thirds
|
||||
# and fourths remain feasible; repeated oscillation is not progress.
|
||||
distance=abs(target.pitch.number()-number)
|
||||
remaining_cost=max(0,distance-5*remaining)/12
|
||||
repeating=len(path)>1 and path[-2].pitch==prior.pitch==pitch
|
||||
value=cost+max(0,abs(motion)-7)/12+.2*repeating
|
||||
following.append((value,(*path,dataclasses.replace(n,pitch=pitch,position=None)),remaining_cost))
|
||||
chosen=generator.solo.sequences.diverse(following,lambda v:(v[0]+v[2],tuple(n.pitch.number() for n in v[1])),
|
||||
lambda v:(generator.solo.sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(v[1],v[1][1:]))),v[1][-1].pitch.number()),8)
|
||||
states=[(cost,path) for cost,path,_ in chosen]
|
||||
if not states:return ()
|
||||
result=[]
|
||||
for _,actual in states:
|
||||
bound=proposal.bind(actual)
|
||||
if not harmony.evaluate(actual,until=actual[-1].end,complete=True,expressions=bound).valid:continue
|
||||
identity=generator.solo.policy.stable_rank('planned-route',tuple((n.start,n.end,n.pitch) for n in actual))[:16]
|
||||
result.append(types.RouteCandidate(identity,actual,actual[-1],(),relationship,tuple('gesture:'+c.rule for c in bound)))
|
||||
return tuple(result)
|
||||
|
||||
def _candidates(notes, fixed, harmony, library, register, purpose='connect'):
|
||||
"""Every returned candidate includes its entry, complete rhythm and destination.
|
||||
|
||||
|
||||
+49
-15
@@ -116,7 +116,9 @@ def rhythm_cost(pattern,length,purpose):
|
||||
isolated=sum(b-a<=F(1,2) and prev[1]<a and b<nxt[0]
|
||||
for prev,(a,b),nxt in zip(pattern,pattern[1:],pattern[2:]))
|
||||
values={b-a for a,b in pattern}
|
||||
uniform=.15 if len(pattern)>=4 and len(values)==1 else 0.
|
||||
# Equal eighths can be a single directional run. Uniformity alone is not a
|
||||
# musical defect; aimless long-value packets are addressed by construction.
|
||||
uniform=.15 if len(pattern)>=4 and len(values)==1 and next(iter(values))>=1 else 0.
|
||||
if purpose in ('continue','connect'):
|
||||
# Silence may articulate an idea; disconnected tiny islands do not carry
|
||||
# unfinished motion. No minimum occupancy or chorus attack quota.
|
||||
@@ -194,6 +196,13 @@ def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operatio
|
||||
# Develop the heard timing, preserving total duration. Eighth/quarter/dotted
|
||||
# alternatives are actual contracts, not failed copies of a rhythmic seed.
|
||||
for original,label in tuple(values.items()):
|
||||
movement=generator.vocabulary.data('melodic_vocabulary')['motion_subdivision']
|
||||
for i,(a,b) in enumerate(original[:-1]):
|
||||
pulse=F(movement['pulse'])
|
||||
if b-a<F(movement['minimum_value']) or (a/pulse).denominator!=1 or ((b-a)/pulse).denominator!=1:continue
|
||||
moving=tuple((a+j*pulse,a+(j+1)*pulse) for j in range(int((b-a)/pulse)))
|
||||
revised=(*original[:i],*moving,*original[i+1:])
|
||||
if len(revised)<=movement['max_attacks']:values[revised]=label+'; moving continuation'
|
||||
for i,(a,b) in enumerate(original):
|
||||
if b-a==1:
|
||||
values[(*original[:i],(a,a+F(1,2)),(a+F(1,2),b),*original[i+1:])]=label+'; diminution'
|
||||
@@ -252,9 +261,10 @@ class Unit:
|
||||
routes: tuple[types.RouteCandidate,...] = ()
|
||||
start: F = F()
|
||||
end: F = F()
|
||||
expressions: tuple[types.ExpressionContract,...] = ()
|
||||
|
||||
|
||||
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None):
|
||||
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None,allow_expressions=True):
|
||||
"""Joint whole-gesture pitch/rhythm actions, conditioned on the actual entry.
|
||||
|
||||
A complete route is proposed before thematic evidence is checked. No motif
|
||||
@@ -263,7 +273,7 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
|
||||
"""
|
||||
from generator.solo import leads
|
||||
key=('joint-gesture-1',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,
|
||||
home,operation,ending,goal,batch,id(harmony),id(library),id(rhythms))
|
||||
home,operation,ending,goal,batch,allow_expressions,getattr(context,'expressions_enabled',True),id(harmony),id(library),id(rhythms))
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits']+=1;return context.results[key]
|
||||
trace=context.diagnostics.setdefault('operations',{}).setdefault(operation,
|
||||
@@ -298,13 +308,31 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
|
||||
(j[4].number()>j[3].number())-(j[4].number()<j[3].number())),policy.DEFAULT.theme_route_jobs*(batch+1))
|
||||
trace['jobs']+=len(jobs);trace['pruned_jobs']+=len(jobs)-len(selected)
|
||||
result=[]
|
||||
for _,templates,label,first,target in selected:
|
||||
from generator.solo import gestures
|
||||
scheduled=[]
|
||||
for rank,templates,label,first,target in selected:
|
||||
timed=(dataclasses.replace(templates[0],pitch=first),*templates[1:-1],dataclasses.replace(templates[-1],pitch=target))
|
||||
proposed=list(routes.candidates(timed,{timed[-1].event_id},harmony,library,harmony.profile.register,
|
||||
operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal)))
|
||||
scheduled.append((rank,gestures.Proposal(timed,(),'direct'),label))
|
||||
if allow_expressions and getattr(context,'expressions_enabled',True):
|
||||
for rank,plain,label in tuple(scheduled[:4]):
|
||||
scheduled.extend((rank,p,label+'; '+p.purpose) for p in gestures.select(plain.notes,harmony,start,finish,ending,limit=4))
|
||||
funnel=context.diagnostics.setdefault('joint_gestures',{})
|
||||
def record(purpose,stage,count=1):
|
||||
values=funnel.setdefault(purpose,dict(proposed=0,retained_jobs=0,valid_routes=0,retained_units=0))
|
||||
values[stage]+=count
|
||||
for _,p,_ in scheduled:record(p.purpose,'proposed')
|
||||
scheduled=sequences.diverse(scheduled,lambda v:(v[0],policy.stable_rank(ref.id,(v[1].purpose,tuple((n.start,n.end,n.pitch) for n in v[1].notes)))),
|
||||
lambda v:tuple(c.kind for c in v[1].contracts) or ('direct',),policy.DEFAULT.theme_route_jobs*(batch+1))
|
||||
for _,proposal,label in scheduled:
|
||||
record(proposal.purpose,'retained_jobs')
|
||||
timed=proposal.notes;first=timed[0].pitch;target=timed[-1].pitch;templates=timed
|
||||
if not max(2,count)<=len(timed)<=16:continue
|
||||
fixed={timed[-1].event_id}|{n.event_id for n in timed if n.role=='arrival'}
|
||||
proposed=list(routes.candidates(timed,fixed,harmony,library,harmony.profile.register,
|
||||
operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal),expressions=proposal.contracts))
|
||||
# A substantial literal return is a musical action, not merely the hope
|
||||
# that a general interval search will accidentally reproduce the theme.
|
||||
if require_feature and len(templates)==len(ref.notes):
|
||||
if proposal.purpose=='direct' and require_feature and len(templates)==len(ref.notes):
|
||||
delta=first.number()-ref.notes[0].pitch.number()
|
||||
actual=[]
|
||||
for template,n in zip(templates,ref.notes):
|
||||
@@ -320,18 +348,22 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
|
||||
contract=feature(ref,actual[:count],treatment=operation if operation!='introduce' else None) if count else None
|
||||
if contract and references.feature_error(contract,actual):
|
||||
trace['rejected_by_rule']+=1;continue
|
||||
checked=harmony.evaluate(actual,until=finish,complete=True)
|
||||
contracts=proposal.bind(actual)
|
||||
checked=harmony.evaluate(actual,until=finish,complete=True,expressions=contracts)
|
||||
if not checked.valid:trace['rejected_by_rule']+=1;continue
|
||||
trace['valid']+=1
|
||||
record(proposal.purpose,'valid_routes')
|
||||
entry=max(0,abs(first.number()-incoming.last.pitch.number())-7)/12 if incoming.last else 0.
|
||||
score=2*checked.exposure+entry+.1*routes.path_cost(actual)
|
||||
score+=.25*rhythm_cost(tuple((n.start-start,n.end-start) for n in actual),finish-start,rhythmic_purpose(operation,terminal))
|
||||
result.append(Unit(actual,contract,ref,operation,ending,score,label,(route,),start,finish))
|
||||
result.append(Unit(actual,contract,ref,operation,ending,score,label,(route,),start,finish,contracts))
|
||||
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
|
||||
chosen=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
|
||||
lambda u:(routes.path_family(u.notes,harmony),u.notes[0].pitch.number()//12,
|
||||
tuple(n.end-n.start for n in u.notes)),policy.DEFAULT.theme_unit_alternatives)
|
||||
tuple(n.end-n.start for n in u.notes),tuple(c.kind for c in u.expressions)),policy.DEFAULT.theme_unit_alternatives)
|
||||
trace['retained']+=len(chosen)
|
||||
for u in chosen:
|
||||
record(u.source.split('; ')[-1] if '; ' in u.source else 'direct','retained_units')
|
||||
context.diagnostics['generated_candidates']+=len(chosen)
|
||||
context.results[key]=chosen
|
||||
return chosen
|
||||
@@ -437,7 +469,7 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
||||
destination=ending.pitch.number() if ending else phrasing.register_goal(state,part,base,harmony.profile)
|
||||
goal=destination if finish==source.end else round(base+(destination-base)*float((finish-cursor)/(source.end-cursor)))
|
||||
options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation,
|
||||
terminal=terminal,batch=batch,ending=ending if terminal else None,goal=goal)
|
||||
terminal=terminal,batch=batch,ending=ending if terminal else None,goal=goal,allow_expressions=stage=='elaborated')
|
||||
for unit in options:
|
||||
if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']:
|
||||
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
|
||||
@@ -466,21 +498,23 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
|
||||
# Cell tails are not compulsory melodic arrivals. Only the selected close
|
||||
# has a sounding contract; intermediate turns remain editable.
|
||||
anchors=(ending,) if ending else ()
|
||||
tails=(types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()),) if ending else ()
|
||||
tails=tuple(types.SustainedTail(n.start,n.end,n.pitch.number()) for n in plain if n.role=='arrival')
|
||||
if ending:tails=(*tails,types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()))
|
||||
options=[(plain,(),())]
|
||||
for notes,edits,selected_routes in options:
|
||||
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,leads.signature(notes))[:12]
|
||||
transforms=tuple(types.Transformation(identifier+f':{i}',u.reference.id,home.id,u.operation,u.notes[0].start,u.notes[-1].end,
|
||||
tuple(n.event_id for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),tuple((n.start-u.notes[0].start,n.end-u.notes[0].start) for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),preserves=(),source=u.reference.id+'; rhythm '+u.source) for i,u in enumerate(units))
|
||||
source_ref=state.source_reference
|
||||
draft=types.MelodicDraft(identifier,source.start,source.end,notes,transforms,(),(),anchors,home,'introduce' if not incoming.home else 'develop',
|
||||
draft=types.MelodicDraft(identifier,source.start,source.end,notes,transforms,tuple(n.event_id for n in notes if n.role=='arrival'),(),anchors,home,'introduce' if not incoming.home else 'develop',
|
||||
features=features,reference=source_ref.id,reference_motif=home,source_phrase=source,source_reference=source_ref,
|
||||
source_links=tuple((n.event_id,n.sources) for n in notes),sustained_tails=tails)
|
||||
source_links=tuple((n.event_id,n.sources) for n in notes),sustained_tails=tails,
|
||||
expressions=tuple(c for u in units for c in u.expressions))
|
||||
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
|
||||
landmarks=(types.Landmark(ending.earliest,ending.latest,ending.pitch.number(),ending.pitch.number(),0,'sounding destination'),) if ending else ()
|
||||
closed=bool(ending and ending.role=='terminal')
|
||||
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,harmony.profile.register,F(5,4),
|
||||
source.end-ending.hold_until if ending else F(),phrasing.activity(source,ending),landmarks,
|
||||
source.end-ending.hold_until if ending else F(),phrasing.activity(source,ending,tuple(n for n in notes if n.role=='arrival')),landmarks,
|
||||
'sustain' if ending else 'connected','closed' if closed else 'open',seed,
|
||||
source.id,source.start,source.end,(anchors,))
|
||||
before_rhythm=draft
|
||||
|
||||
@@ -44,6 +44,13 @@ def _load(contents):
|
||||
raise ValueError(p['id']+': invalid chord-member step')
|
||||
if not p['prefix_attacks'] or any(n not in (2,3,4) for n in p['prefix_attacks']) or not 1<=p['max_interval']<=12:
|
||||
raise ValueError(p['id']+': unsupported bounded chord prefix')
|
||||
gestures=result['melodic_vocabulary']['gesture_rules']
|
||||
if len({g['id'] for g in gestures})!=len(gestures):raise ValueError('Duplicate joint gesture rule')
|
||||
for g in gestures:
|
||||
if g['kind'] not in ('harmonic_arrival','sustained_color'):raise ValueError(g['id']+': unsupported joint gesture')
|
||||
if g['kind']=='harmonic_arrival':
|
||||
if not g['arrival_offsets'] or any(abs(F(v))>F(result['harmony']['anticipation_max']) for v in g['arrival_offsets']):raise ValueError(g['id']+': arrival outside bounded policy')
|
||||
if not g['holds'] or any(F(v)<=0 for v in g['holds']) or F(g['release'])<0:raise ValueError(g['id']+': invalid sounding interval')
|
||||
return result
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
|
||||
Reference in New Issue
Block a user