This commit is contained in:
khodowany
2026-10-04 17:14:13 -05:00
parent f234dd6d51
commit 68df7292ee
33 changed files with 990 additions and 1872 deletions
-431
View File
@@ -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()
+11 -6
View File
@@ -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]))))
+6 -3
View File
@@ -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
+149
View File
@@ -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)
+2
View File
@@ -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
-156
View File
@@ -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()
+12 -1
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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)))
+77 -1
View File
@@ -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
View File
@@ -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