@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
@@ -16,6 +17,16 @@ class Analysis:
|
||||
tempo: fractions.Fraction
|
||||
melody: tuple[generator.solo.types.Note, ...]
|
||||
|
||||
def identity(analysis, measures, part):
|
||||
anchor = analysis.melody[0].pitch.number() if analysis.melody else 0
|
||||
melody = [(str(n.start), str(n.end), n.pitch.number() - anchor) for n in analysis.melody]
|
||||
harmony = []
|
||||
for measure in measures:
|
||||
for chord in measure['harmonies']:
|
||||
pitches = sorted((int(p) - anchor) % 12 for p in generator.harmony.pitch_classes(chord)) if chord['kind'] != 'none' else []
|
||||
harmony.append((chord['kind'], pitches, chord['onset']))
|
||||
return hashlib.sha256(json.dumps((part, melody, harmony), sort_keys=True).encode()).hexdigest()
|
||||
|
||||
def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]:
|
||||
"""Collapse tied tones before extracting monophonic melody fragments."""
|
||||
result, pending = [], {}
|
||||
|
||||
@@ -84,7 +84,7 @@ def backing(baseline, foreground, part, rhythms, references, choices, initial=No
|
||||
generator.model.validate_document(document)
|
||||
return document['staves'][0]['measures']
|
||||
|
||||
def apply(source, parts, *, rhythms, scales, sequences):
|
||||
def apply(source, parts, *, rhythms, scales, sequences, solos=None):
|
||||
"""Compose once per song, replacing only already-placed solo section windows."""
|
||||
analysis = generator.solo.analysis.analyze(source)
|
||||
sections = regions(source)
|
||||
@@ -103,6 +103,8 @@ def apply(source, parts, *, rhythms, scales, sequences):
|
||||
if sounding:
|
||||
cutoff -= generator.model.fraction(pickup['duration']) - min(sounding)
|
||||
solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial)
|
||||
if solos is not None:
|
||||
solos.append(solo)
|
||||
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
|
||||
generator.solo.notation.replace_region(parts[part], first, foreground)
|
||||
annotate(parts[part], solo, offset)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
"""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 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
|
||||
|
||||
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 capture(root, stage):
|
||||
destination = root / stage
|
||||
if destination.exists():
|
||||
raise ValueError(f'{destination}: snapshot already exists; choose a new output root')
|
||||
rhythms = generator.rhythm.load_library(pathlib.Path('data/rhythms'))
|
||||
scales = generator.scales.library.load_library(pathlib.Path('data/scales'))
|
||||
sequences = generator.solo.sequences.load_library(pathlib.Path('data/sequences'))
|
||||
result = {'stage': stage, 'implementation': fingerprint(), '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')):
|
||||
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 = []
|
||||
generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos)
|
||||
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)}
|
||||
print(path.stem, solo.part, json.dumps(song['solos'][solo.part]['metrics']), flush=True)
|
||||
result['songs'][path.stem] = song
|
||||
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 render(root):
|
||||
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['baseline']['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'
|
||||
first, count = solo['first'], min(8, solo['count'])
|
||||
foreground = excerpt(song['parts'][part], first, count)
|
||||
prefix = root / stage / f'{song_name}_{part}'
|
||||
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), foreground)
|
||||
prefix.with_suffix('.txt').write_text(solo['report'])
|
||||
for responsive in [False, True] if stage == 'D' else [False]:
|
||||
backing = excerpt((song if responsive else baseline)['parts'][other], first, count)
|
||||
path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav')
|
||||
samples = mix((foreground, backing), fractions.Fraction(solo['tempo']), root, cache)
|
||||
clips.append((path, samples))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
|
||||
# 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_index(root, snapshots)
|
||||
print(f'Rendered {len(clips)} comparisons under {root}', flush=True)
|
||||
|
||||
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 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)
|
||||
action.add_argument('--render', action='store_true')
|
||||
args = parser.parse_args()
|
||||
capture(args.output, args.capture) if args.capture else render(args.output)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+64
-19
@@ -10,6 +10,7 @@ import generator.scales.selection
|
||||
import generator.scales.timeline
|
||||
import generator.solo.analysis
|
||||
import generator.solo.planning
|
||||
import generator.solo.policy
|
||||
import generator.solo.realization
|
||||
import generator.solo.types
|
||||
|
||||
@@ -21,27 +22,71 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
|
||||
if choice.span.chord and choice.scale is None:
|
||||
raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog')
|
||||
plans = generator.solo.planning.plans(analysis, measures, choices, end, part, sequences)
|
||||
# A protected quote may change the local collection, never its individual pitches.
|
||||
for plan in plans:
|
||||
if plan.treatment != 'quote' or plan.motif.duration > plan.end - plan.start:
|
||||
continue
|
||||
retained = tuple((plan.start + n.start, plan.start + n.end, {n.pitch.number() % 12}) for n in plan.motif.notes)
|
||||
if all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
|
||||
continue
|
||||
alternative = generator.scales.selection.select(timeline, region, scales, retained=retained)
|
||||
if all(pc <= generator.solo.types.permitted(alternative, start, finish) for start, finish, pc in retained) and all(c.scale or c.span.chord is None for c in alternative):
|
||||
choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in alternative)
|
||||
phrases, previous = [], None
|
||||
for plan in plans:
|
||||
phrase = generator.solo.realization.compose(plan, measures, choices, sequences, rhythms, analysis.tempo, previous)
|
||||
phrases.append(phrase)
|
||||
notes = tuple(n for d in phrase.decisions for n in d.notes)
|
||||
if notes:
|
||||
previous = notes[-1]
|
||||
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end)
|
||||
retained = tuple((plan.start + n.start, plan.start + n.end, {n.pitch.number() % 12}) for plan in plans if plan.treatment in ('quote', 'exact_quote') for n in plan.motif.notes)
|
||||
if retained and not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
|
||||
choices = generator.scales.selection.select(timeline, region, scales, retained=retained)
|
||||
if not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
|
||||
raise ValueError(f'{part} solo: protected quotation has no exact compatible scale collection')
|
||||
choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in choices)
|
||||
context = generator.solo.realization.SearchContext()
|
||||
|
||||
def realize(plan, previous):
|
||||
return generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context)
|
||||
|
||||
phrases = choose_phrases(plans, realize)
|
||||
context.diagnostics['policy'] = generator.solo.policy.describe()['fingerprint']
|
||||
context.diagnostics['musical_identity'] = generator.solo.analysis.identity(analysis, measures, part)
|
||||
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end, context.diagnostics)
|
||||
validate(result)
|
||||
return result
|
||||
|
||||
def phrase_notes(phrase):
|
||||
return generator.solo.types.sounding_notes(n for d in phrase.decisions for n in d.notes)
|
||||
|
||||
def transition_cost(first, second):
|
||||
before, after = phrase_notes(first), phrase_notes(second)
|
||||
if not before or not after:
|
||||
return 0.0
|
||||
gap = max(0, after[0].start - before[-1].end - first.plan.release)
|
||||
jump = abs(after[0].pitch.number() - before[-1].pitch.number())
|
||||
# The next phrase's local score already includes physical movement. Here we
|
||||
# evaluate pacing and conversational contrast at the join, once per pair.
|
||||
before_shape = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(before, before[1:]))
|
||||
after_shape = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(after, after[1:]))
|
||||
repeated_answer = second.plan.relationship == 'answer' and before_shape == after_shape
|
||||
return min(1, float(gap) / 2) + 0.5 * min(1, max(0, jump - 5) / 12) + 0.5 * repeated_answer
|
||||
|
||||
def choose_phrases(plans, realize):
|
||||
"""Commit only after the next phrase has been evaluated from each ending."""
|
||||
result, previous = [], None
|
||||
for index, plan in enumerate(plans):
|
||||
options = realize(plan, previous)
|
||||
ranked, failure = [], None
|
||||
for option in options:
|
||||
notes = phrase_notes(option)
|
||||
last = notes[-1] if notes else previous
|
||||
incoming = transition_cost(result[-1], option) if result else 0
|
||||
if index + 1 == len(plans):
|
||||
ranked.append((option.score + incoming, option))
|
||||
continue
|
||||
try:
|
||||
following = realize(plans[index + 1], last)
|
||||
except generator.solo.realization.SearchExhausted as error:
|
||||
failure = error
|
||||
continue
|
||||
length = float(option.plan.end - option.plan.start)
|
||||
next_length = float(plans[index + 1].end - plans[index + 1].start)
|
||||
cost = min((option.score * length + future.score * next_length) / (length + next_length) + transition_cost(option, future) for future in following)
|
||||
ranked.append((cost + incoming, option))
|
||||
if not ranked:
|
||||
raise failure or generator.solo.realization.SearchExhausted(f'{plan.id}: no feasible phrase transition')
|
||||
_, chosen = min(ranked, key=lambda item: (item[0], tuple((n.pitch, n.start, n.end, n.position) for n in phrase_notes(item[1]))))
|
||||
result.append(chosen)
|
||||
notes = phrase_notes(chosen)
|
||||
if notes:
|
||||
previous = notes[-1]
|
||||
return tuple(result)
|
||||
|
||||
def validate(solo):
|
||||
previous = None
|
||||
for note in solo.notes():
|
||||
@@ -53,6 +98,6 @@ def validate(solo):
|
||||
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
|
||||
previous = note
|
||||
for phrase in solo.phrases:
|
||||
notes = tuple(n for d in phrase.decisions for n in d.notes)
|
||||
notes = phrase_notes(phrase)
|
||||
if not generator.solo.realization.target_ok(notes, phrase.plan.target):
|
||||
raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized')
|
||||
|
||||
@@ -26,8 +26,14 @@ def place(notes: tuple[generator.solo.types.Note, ...], tempo: fractions.Fractio
|
||||
following = []
|
||||
for position in positions(note.pitch.number()):
|
||||
current = dataclasses.replace(note, position=position)
|
||||
candidates = [(cost + travel(path[-1] if path else previous, current, tempo), (*path, current)) for cost, path in states]
|
||||
following.append(min(candidates, key=lambda item: (item[0], tuple(n.position for n in item[1]))))
|
||||
candidates = []
|
||||
for cost, path in states:
|
||||
prior = path[-1] if path else previous
|
||||
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start):
|
||||
continue
|
||||
candidates.append((cost + (0 if note.continuation else travel(prior, current, tempo)), (*path, current)))
|
||||
if candidates:
|
||||
following.append(min(candidates, key=lambda item: (item[0], tuple(n.position for n in item[1]))))
|
||||
if not following:
|
||||
return None
|
||||
states = following
|
||||
|
||||
+79
-12
@@ -6,6 +6,7 @@ import generator.harmony
|
||||
import generator.model
|
||||
import generator.scales.selection
|
||||
import generator.solo.analysis
|
||||
import generator.solo.policy
|
||||
import generator.solo.sequences
|
||||
import generator.solo.types
|
||||
|
||||
@@ -19,16 +20,44 @@ def target_pitches(chord: dict, final: bool) -> frozenset[int]:
|
||||
root = generator.scales.selection.root_number(chord)
|
||||
return frozenset(int(root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items() if d in preferred) or frozenset(generator.harmony.pitch_classes(chord))
|
||||
|
||||
def destination(choices, start, end, final=False, release=fractions.Fraction(1, 2)):
|
||||
def destination(choices, start, end, final=False, release=fractions.Fraction(1, 2), closure='closed'):
|
||||
last = next((c for c in reversed(choices) if c.span.start < end and c.span.end > start), None)
|
||||
if not last or not last.span.chord:
|
||||
return None
|
||||
latest = end - release - fractions.Fraction(1, 4)
|
||||
if last.span.start > latest:
|
||||
return None
|
||||
return generator.solo.types.TargetEvent(max(start, last.span.start, end - 1 - release), latest, target_pitches(last.span.chord, final), generator.harmony.symbol(last.span.chord), 'phrase resolution' if final else 'defining chord-tone arrival')
|
||||
pitches = target_pitches(last.span.chord, final)
|
||||
if closure == 'open' and last.scale:
|
||||
root = generator.scales.selection.root_number(last.span.chord)
|
||||
degrees = generator.harmony.degrees(last.span.chord)
|
||||
additions = {int(root + generator.harmony.degree_semitones(d, degrees.get(d, 0))) % 12 for d in (5, 9, 11, 13) if d not in {v['value'] for v in last.span.chord.get('degrees', []) if v['type'] == 'subtract'}}
|
||||
pitches = frozenset(additions & last.scale.pitches) or pitches
|
||||
return generator.solo.types.TargetEvent(max(start, last.span.start, end - 2 - release), latest, pitches, generator.harmony.symbol(last.span.chord), 'open continuation' if closure == 'open' else 'phrase resolution' if final else 'defining chord-tone arrival')
|
||||
|
||||
def new_motif(sequences: generator.solo.sequences.Library, length: fractions.Fraction) -> generator.solo.types.Motif:
|
||||
def new_motif(sequences: generator.solo.sequences.Library, length: fractions.Fraction, *, choices=(), register=(55, 74), seed='', variant=0) -> generator.solo.types.Motif:
|
||||
if choices and any(c.scale for c in choices):
|
||||
scale = next(c.scale for c in choices if c.scale)
|
||||
arrival = next(c for c in reversed(choices) if c.scale)
|
||||
count = min(6, max(3, int(length * fractions.Fraction(3, 2))))
|
||||
shapes = (('rising', 4), ('falling', -4), ('arch', 1), ('valley', -1), ('leap_return', 2), ('neighbor', 0))
|
||||
shape, displacement = shapes[(int(generator.solo.policy.stable_rank(seed, 'invention')[:8], 16) + variant) % len(shapes)]
|
||||
paths = sequences.retrieve(count, displacement, (displacement > 0) - (displacement < 0))
|
||||
candidates = []
|
||||
target = target_pitches(arrival.span.chord, False)
|
||||
for path in paths:
|
||||
for start in range(-14, 15):
|
||||
pitches = tuple(generator.solo.types.scale_pitch(scale, start + offset) for offset in path.offsets())
|
||||
if not all(register[0] <= pitch.number() <= register[1] for pitch in pitches):
|
||||
continue
|
||||
score = (generator.solo.sequences.family(path.steps) != shape, pitches[-1].number() % 12 not in target, abs(path.offsets()[-1] - displacement), abs(pitches[0].number() - (register[0] + 5)), generator.solo.policy.stable_rank(seed, (variant, path.steps, start)))
|
||||
candidates.append((score, pitches, path))
|
||||
if candidates:
|
||||
_, pitches, path = min(candidates, key=lambda c: c[0])
|
||||
unit = min(fractions.Fraction(1, 2), length / count)
|
||||
notes = tuple(generator.solo.types.Note(i * unit, (i + 1) * unit, pitch) for i, pitch in enumerate(pitches))
|
||||
identifier = generator.solo.policy.stable_rank(seed, (variant, path.steps))[:12]
|
||||
return generator.solo.types.Motif('invented_' + identifier, notes, length, (), 'sequences ' + ' + '.join(path.sources))
|
||||
paths = sequences.connecting(3) or sequences.connecting(2) or sequences.connecting(4)
|
||||
if not paths:
|
||||
raise ValueError('solo composition requires a two-, three-, or four-note sequence')
|
||||
@@ -49,11 +78,33 @@ def choose_motifs(analysis, sequences, length):
|
||||
contrast = min(others, key=lambda m: (m.contour() == head.contour(), abs(len(m.notes) - 3), m.duration, m.id)) if others else head
|
||||
return head, contrast, new_motif(sequences, length)
|
||||
|
||||
def activity_passages(start, end, release, activity, shape=0):
|
||||
music_end = end - release
|
||||
boundaries = sorted({start, music_end} | {start + fractions.Fraction(round(float((end - start) * i / 4) * 2), 2) for i in (1, 2, 3) if start < start + fractions.Fraction(round(float((end - start) * i / 4) * 2), 2) < music_end})
|
||||
weights = ((1, 1.6, 1.1), (0.8, 1.7, 1.2), (1.5, 1.4, 0.8))[shape % 3]
|
||||
spans = list(zip(boundaries, boundaries[1:]))
|
||||
arrival_rate = fractions.Fraction(3, 8)
|
||||
desired = max(0, activity * (end - start) - arrival_rate * (spans[-1][1] - spans[-1][0]))
|
||||
denominator = sum(float(b - a) * weights[min(i, 2)] for i, (a, b) in enumerate(spans[:-1]))
|
||||
result = []
|
||||
for i, (a, b) in enumerate(spans):
|
||||
arrival = i == len(spans) - 1
|
||||
rate = arrival_rate if arrival else fractions.Fraction(str(min(2, float(desired) * weights[min(i, 2)] / max(denominator, 0.001)))).limit_denominator(1000)
|
||||
occupancy = generator.solo.policy.DEFAULT.arrival_occupancy if arrival else generator.solo.policy.DEFAULT.active_occupancy
|
||||
result.append(generator.solo.types.Passage(a, b, 'sustained_arrival' if arrival else 'active', rate, fractions.Fraction(str(occupancy))))
|
||||
if release:
|
||||
result.append(generator.solo.types.Passage(music_end, end, 'space', fractions.Fraction(), fractions.Fraction()))
|
||||
return tuple(result)
|
||||
|
||||
def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choices, end: fractions.Fraction, part: str, sequences: generator.solo.sequences.Library) -> tuple[generator.solo.types.PhrasePlan, ...]:
|
||||
boundaries = [fractions.Fraction()]
|
||||
for measure in measures:
|
||||
boundaries.append(min(end, boundaries[-1] + generator.model.fraction(measure['duration'])))
|
||||
head, contrast, invented = choose_motifs(analysis, sequences, boundaries[1])
|
||||
seed = generator.solo.policy.stable_rank(generator.solo.analysis.identity(analysis, measures, part), generator.solo.policy.describe()['fingerprint'])
|
||||
corridor = (55, 74) if part == 'acoustic' else (59, 79)
|
||||
invented = new_motif(sequences, boundaries[1], choices=choices, register=corridor, seed=seed)
|
||||
alternate = new_motif(sequences, boundaries[1], choices=choices, register=corridor, seed=seed, variant=1)
|
||||
result, index = [], 0
|
||||
while index < len(measures) and boundaries[index] < end:
|
||||
start = boundaries[index]
|
||||
@@ -74,17 +125,33 @@ def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choi
|
||||
finish = boundaries[stop]
|
||||
final = finish == end
|
||||
phase = (start + finish) / (2 * end)
|
||||
motif = head if part == 'acoustic' or final else invented
|
||||
if len(result) > 1 and not final and len(result) % 3 == 2:
|
||||
motif = contrast if part == 'acoustic' else invented
|
||||
relationship = 'statement' if not result else 'callback' if final else 'repeat' if len(result) == 1 else 'answer' if len(result) % 2 else 'variation'
|
||||
treatment = 'quote' if (part == 'acoustic' and not result) or (part == 'electric' and final and motif.origin == 'melody') else 'thematic'
|
||||
phrase_seed = generator.solo.policy.stable_rank(seed, (str(start), str(finish)))
|
||||
choice = int(phrase_seed[:8], 16)
|
||||
relationship = 'statement' if not result else 'repeat' if len(result) == 1 else 'answer' if len(result) % 2 else 'variation'
|
||||
if part == 'acoustic':
|
||||
motif = head if len(result) < 2 else (head, contrast, invented)[choice % 3]
|
||||
else:
|
||||
motif = invented if len(result) < 2 else (invented, alternate, head)[choice % 3]
|
||||
treatment = 'opening_reference' if not result and motif.origin == 'melody' else 'invented' if motif.origin != 'melody' else 'answer' if relationship == 'answer' else ('rhythmic_reference', 'contour_reference')[choice % 2]
|
||||
low, high = (55, 74) if part == 'acoustic' else (59, 79)
|
||||
# Reserve the upper register for a prepared peak, not every active passage.
|
||||
register = (low + (5 if fractions.Fraction(1, 2) <= phase < fractions.Fraction(3, 4) else 0), high if phase >= fractions.Fraction(1, 2) else high - 4)
|
||||
activity = fractions.Fraction(3, 4) if part == 'acoustic' else fractions.Fraction(1)
|
||||
if final:
|
||||
activity *= fractions.Fraction(2, 3)
|
||||
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', start, finish, motif, relationship, treatment, destination(choices, start, finish, final), register, activity))
|
||||
activity = fractions.Fraction(str(generator.solo.policy.DEFAULT.acoustic_activity if part == 'acoustic' else generator.solo.policy.DEFAULT.electric_activity))
|
||||
ending = ('connected', 'sustain', 'breath')[choice % 3]
|
||||
release = fractions.Fraction(1, 2) if choice % 2 else fractions.Fraction(1)
|
||||
release = min(release, (finish - start) / 4) if ending == 'breath' else fractions.Fraction()
|
||||
if treatment == 'rhythmic_reference' and motif.duration > finish - start - release:
|
||||
treatment = 'contour_reference'
|
||||
closure = 'closed' if final or relationship == 'answer' else 'open'
|
||||
passages = activity_passages(start, finish, release, activity, choice % 3)
|
||||
sounding = [p for p in passages if p.intent != 'space']
|
||||
peak = sounding[min(1, len(sounding) - 1)]
|
||||
ascending = choice % 2 == 0
|
||||
landmarks = (
|
||||
generator.solo.types.Landmark(start, min(start + 1, peak.start), register[0] + (3 if ascending else 8), register[0] + (7 if ascending else 12), 0, 'entrance'),
|
||||
generator.solo.types.Landmark(peak.start, peak.end, register[1] - 6 if ascending else register[0] + 2, register[1] - 2 if ascending else register[0] + 6, 1 if ascending else -1, 'peak' if ascending else 'low point'),
|
||||
generator.solo.types.Landmark(sounding[-1].start, sounding[-1].end, register[0] + (4 if ascending else 8), register[0] + (9 if ascending else 13), -1 if ascending else 1, 'arrival'),
|
||||
)
|
||||
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', start, finish, motif, relationship, treatment, destination(choices, start, finish, final, release, closure), register, activity, release, passages, landmarks, ending, closure, phrase_seed))
|
||||
index = stop
|
||||
return tuple(result)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""One curated, deterministic composition policy; all times are quarter-note beats."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Policy:
|
||||
acoustic_activity: float = 1.125
|
||||
electric_activity: float = 1.375
|
||||
active_occupancy: float = 0.75
|
||||
arrival_occupancy: float = 0.9
|
||||
weights: tuple[tuple[str, float], ...] = (('activity', 4), ('occupancy', 4), ('fidelity', 3), ('landmarks', 3), ('register', 1), ('contour', 1), ('connection', 1), ('repetition', 1), ('movement', 0.5))
|
||||
beam_width: int = 64
|
||||
extensions: int = 32
|
||||
retrieval: int = 32
|
||||
alternatives: int = 4
|
||||
|
||||
DEFAULT = Policy()
|
||||
|
||||
def describe():
|
||||
values = dataclasses.asdict(DEFAULT)
|
||||
identity = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()
|
||||
return {'fingerprint': identity, 'parameters': values}
|
||||
|
||||
def stable_rank(seed, value):
|
||||
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
|
||||
+203
-118
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
import heapq
|
||||
import collections
|
||||
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
@@ -11,10 +11,13 @@ import generator.rhythm
|
||||
import generator.scales.selection
|
||||
import generator.solo.placement
|
||||
import generator.solo.planning
|
||||
import generator.solo.scoring
|
||||
import generator.solo.sequences
|
||||
import generator.solo.policy
|
||||
import generator.solo.types
|
||||
|
||||
BEAM_WIDTH = 64
|
||||
CELL_LIMIT = 32
|
||||
BEAM_WIDTH = generator.solo.policy.DEFAULT.beam_width
|
||||
CELL_LIMIT = generator.solo.policy.DEFAULT.extensions
|
||||
|
||||
class SearchExhausted(ValueError):
|
||||
pass
|
||||
@@ -32,8 +35,9 @@ class Candidate:
|
||||
source: str
|
||||
treatment: str
|
||||
rank: tuple
|
||||
fidelity: float = 0
|
||||
|
||||
def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fractions.Fraction, activity: fractions.Fraction):
|
||||
def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fractions.Fraction, activity: fractions.Fraction, offset=fractions.Fraction()):
|
||||
result = {}
|
||||
for snippet in library.for_meter(meter):
|
||||
for index, bar in enumerate(snippet.bars):
|
||||
@@ -44,54 +48,52 @@ def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fract
|
||||
if event.continuation:
|
||||
if spans and spans[-1][1] == event.onset:
|
||||
spans[-1] = (spans[-1][0], event.onset + event.duration)
|
||||
else:
|
||||
# A continuation without its attack is not an independent cell.
|
||||
spans = []
|
||||
break
|
||||
else:
|
||||
spans.append((event.onset, event.onset + event.duration))
|
||||
pattern = tuple(spans)
|
||||
if all(end <= length for _, end in pattern) and len(pattern) <= 4:
|
||||
result.setdefault(pattern, f'rhythm {snippet.id}:{index + 1}')
|
||||
ranked = sorted(result.items(), key=lambda item: (abs(len(item[0]) - length * activity), -sum(end - start for start, end in item[0]), item[1]))
|
||||
return tuple((identifier, pattern) for pattern, identifier in ranked[:8])
|
||||
# Slice exact authored rhythm; later attacks outside the window do not
|
||||
# disqualify a useful prefix. A tied continuation never becomes an attack.
|
||||
pattern = tuple((start - offset, min(end - offset, length)) for start, end in spans if offset <= start < offset + length)
|
||||
result.setdefault(pattern, f'rhythm {snippet.id}:{index + 1}@{offset}+{length}')
|
||||
|
||||
def rank(item):
|
||||
pattern, identifier = item
|
||||
return abs(len(pattern) - length * activity), abs(sum(end - start for start, end in pattern) - length * fractions.Fraction(3, 4)), identifier
|
||||
|
||||
def family(item):
|
||||
pattern = item[0]
|
||||
return len(pattern), bool(pattern and pattern[0][0]), any(a.denominator > 1 for a, _ in pattern)
|
||||
|
||||
selected = generator.solo.sequences.diverse(result.items(), rank, family, 12)
|
||||
return tuple((identifier, pattern) for pattern, identifier in selected)
|
||||
|
||||
def cells(plan, measures, rhythms):
|
||||
result, start, index = [], fractions.Fraction(), 0
|
||||
boundaries = [fractions.Fraction()]
|
||||
result, boundaries = [], [fractions.Fraction()]
|
||||
for measure in measures:
|
||||
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
|
||||
start = plan.start
|
||||
music_end = plan.end - plan.release
|
||||
arrival_fits = not plan.target or plan.motif.duration < music_end - start or any(plan.target.earliest <= start + n.start <= plan.target.latest for n in plan.motif.notes)
|
||||
if plan.motif.duration <= music_end - start and arrival_fits:
|
||||
start, music_end = plan.start, plan.end - plan.release
|
||||
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
|
||||
if protected:
|
||||
motif = plan.motif
|
||||
if not motif.notes or motif.duration > music_end - start:
|
||||
raise SearchExhausted(f'{plan.id}: protected motif rhythm does not fit the phrase')
|
||||
spans = tuple((n.start, n.end) for n in motif.notes)
|
||||
patterns = [(motif.id, spans)]
|
||||
if plan.relationship == 'variation' and spans[-1][1] + fractions.Fraction(1, 2) <= motif.duration:
|
||||
patterns.insert(0, (motif.id + ':displaced', tuple((a + fractions.Fraction(1, 2), b + fractions.Fraction(1, 2)) for a, b in spans)))
|
||||
result.append(Cell(start, start + motif.duration, tuple(patterns), motif))
|
||||
result.append(Cell(start, start + motif.duration, ((motif.id, spans),), motif))
|
||||
start += motif.duration
|
||||
elif plan.motif.notes and plan.motif.duration > music_end - start:
|
||||
# Fragmentation is an explicit transformation, never a truncated quotation.
|
||||
available = (plan.target.earliest if plan.target else music_end) - start
|
||||
notes = tuple(n for n in plan.motif.notes if n.end <= available)
|
||||
if notes:
|
||||
motif = dataclasses.replace(plan.motif, notes=notes, duration=notes[-1].end)
|
||||
result.append(Cell(start, start + motif.duration, ((motif.id + ':fragment', tuple((n.start, n.end) for n in notes)),), motif))
|
||||
start += motif.duration
|
||||
while start < music_end:
|
||||
index = next(i for i in range(len(measures)) if boundaries[i] <= start < boundaries[i + 1])
|
||||
end = min(music_end, boundaries[index + 1])
|
||||
patterns = rhythm_patterns(rhythms, measures[index]['time_signature'], end - start, plan.activity)
|
||||
# These primitives are deliberate alternatives, independent of catalog counts.
|
||||
passage = next((p for p in plan.passages if p.start <= start < p.end), None)
|
||||
end = min(music_end, boundaries[index + 1], passage.end if passage else music_end)
|
||||
activity = passage.activity if passage else plan.activity
|
||||
patterns = rhythm_patterns(rhythms, measures[index]['time_signature'], end - start, activity, start - boundaries[index])
|
||||
patterns += (('hold', ((fractions.Fraction(), end - start),)), ('space', ()))
|
||||
if plan.target and start <= plan.target.latest < end:
|
||||
if plan.target and start <= plan.target.latest and plan.target.earliest < end:
|
||||
onset = max(start, plan.target.earliest) - start
|
||||
patterns = (('arrival_and_release', ((onset, min(end - start, onset + 1)),)), *patterns)
|
||||
result.append(Cell(start, end, patterns))
|
||||
patterns = (('arrival_and_release', ((onset, end - start),)), *patterns)
|
||||
motif = plan.motif if not result else None
|
||||
result.append(Cell(start, end, patterns, motif))
|
||||
start = end
|
||||
result.append(Cell(music_end, plan.end, (('planned_release', ()),)))
|
||||
if plan.release:
|
||||
result.append(Cell(music_end, plan.end, (('planned_release', ()),)))
|
||||
return tuple(result)
|
||||
|
||||
@functools.lru_cache(maxsize=2048)
|
||||
@@ -110,28 +112,25 @@ def target_ok(notes, target):
|
||||
return target is None or any(target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches for n in notes)
|
||||
|
||||
def thematic_paths(cell, spans, choices, plan):
|
||||
states = [(0, ())]
|
||||
states = [(0.0, ())]
|
||||
for index, (start, end) in enumerate(spans):
|
||||
following = []
|
||||
for cost, path in states:
|
||||
available = options(choices, start, end, plan.register)
|
||||
changing_ending = plan.relationship == 'answer' and index == len(spans) - 1
|
||||
if changing_ending:
|
||||
chord = next((c.span.chord for c in choices if c.span.start <= start < c.span.end), None)
|
||||
if chord:
|
||||
available = tuple(p for p in available if p.number() % 12 in generator.solo.planning.target_pitches(chord, False))
|
||||
available = options(choices, start, end, plan.register)
|
||||
for _, path in states:
|
||||
for pitch in available:
|
||||
reference = cell.motif.notes[index]
|
||||
expected = cell.motif.contour()[index - 1] if index else 0
|
||||
motion = pitch.number() - path[-1].pitch.number() if path else 0
|
||||
# Repeated notes are identity, not missing sequence transitions.
|
||||
mismatch = int(bool(index) and ((expected == 0) != (motion == 0) or expected * motion < 0))
|
||||
reference = cell.motif.notes[min(len(cell.motif.notes) - 1, index * len(cell.motif.notes) // max(1, len(spans)))]
|
||||
note = generator.solo.types.Note(start, end, pitch, reference.sources, 'motif')
|
||||
following.append((cost + (0 if changing_ending else 8 * mismatch + abs(motion - expected)), (*path, note)))
|
||||
states = sorted(following, key=lambda item: (item[0], tuple(n.pitch for n in item[1])))[:BEAM_WIDTH]
|
||||
notes = (*path, note)
|
||||
cost = generator.solo.scoring.motif_fidelity(notes, cell.motif, plan.treatment)
|
||||
direction = plan.landmarks[1].direction if len(plan.landmarks) > 1 else 0
|
||||
motion = notes[-1].pitch.number() - notes[0].pitch.number()
|
||||
cost += 0.1 * (direction and motion * direction < 0)
|
||||
cost += sum(generator.solo.scoring.corridor(n.pitch.number(), *plan.register) for n in notes) / (12 * len(notes))
|
||||
following.append((cost, notes))
|
||||
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], tuple(n.pitch for n in item[1])), lambda item: (item[1][-1].pitch.number(), generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(item[1], item[1][1:])))), BEAM_WIDTH))
|
||||
if not states:
|
||||
break
|
||||
return states
|
||||
return [(generator.solo.scoring.motif_fidelity(notes, cell.motif, plan.treatment), notes) for _, notes in states]
|
||||
|
||||
def adapt_path(pitches, spans, choices, register):
|
||||
"""Connect actual pitches across collections without carrying a stale degree index."""
|
||||
@@ -152,102 +151,188 @@ def sequence_paths(spans, choices, plan, sequences):
|
||||
first_scale = next((c.scale for c in choices if c.span.start <= spans[0][0] < c.span.end), None)
|
||||
if first_scale is None:
|
||||
return []
|
||||
paths = []
|
||||
allowed = tuple(generator.solo.types.permitted(choices, start, end) for start, end in spans)
|
||||
changing = any(classes != allowed[0] for classes in allowed)
|
||||
sequence_options = sequences.connecting(len(spans))[:16]
|
||||
offsets = [(s.offsets(), 'sequence ' + s.id) for s in sequence_options]
|
||||
# Single notes and explicit repetitions do not pretend to be catalog paths.
|
||||
offsets.append(((0,) * len(spans), 'single' if len(spans) == 1 else 'repeated_note'))
|
||||
for steps, identifier in offsets:
|
||||
for target_index in range(-14, 21):
|
||||
start_index = target_index - steps[-1]
|
||||
pitches = tuple(generator.solo.types.scale_pitch(first_scale, start_index + step) for step in steps)
|
||||
if not all(allowed):
|
||||
return []
|
||||
available = options(choices, *spans[0], plan.register)
|
||||
if len(spans) == 1:
|
||||
return [((generator.solo.types.Note(*spans[0], pitch, role='path'),), 'single') for pitch in available]
|
||||
landmark = next((p for p in plan.landmarks if p.latest >= spans[0][0]), None)
|
||||
center = (landmark.low + landmark.high) // 2 if landmark else sum(plan.register) // 2
|
||||
direction = landmark.direction if landmark else 0
|
||||
starts = sorted(available, key=lambda p: (generator.solo.scoring.corridor(p.number(), *plan.register), abs(p.number() - center), p))[:8]
|
||||
paths, identities = [], set()
|
||||
for first in starts:
|
||||
degree = next(i for i in range(-21, 28) if generator.solo.types.scale_pitch(first_scale, i) == first)
|
||||
destination = center
|
||||
if plan.target and spans[-1][0] >= plan.target.earliest:
|
||||
compatible = [p for p in options(choices, *spans[-1], plan.register) if p.number() % 12 in plan.target.pitches]
|
||||
if compatible:
|
||||
destination = min(compatible, key=lambda p: (abs(p.number() - center), p)).number()
|
||||
retrieved = sequences.retrieve(len(spans), round((destination - first.number()) / 2), direction)
|
||||
routes = [(p.offsets(), 'sequences ' + ' + '.join(p.sources)) for p in retrieved]
|
||||
routes.append(((0,) * len(spans), 'repeated_note'))
|
||||
for offsets, identifier in routes:
|
||||
pitches = tuple(generator.solo.types.scale_pitch(first_scale, degree + step) for step in offsets)
|
||||
if not all(max(41, plan.register[0] - 7) <= p.number() <= min(84, plan.register[1] + 5) for p in pitches):
|
||||
continue
|
||||
if all(pitch.number() % 12 in classes for pitch, classes in zip(pitches, allowed)):
|
||||
notes = tuple(generator.solo.types.Note(start, end, pitch, role='path') for (start, end), pitch in zip(spans, pitches))
|
||||
paths.append((notes, identifier))
|
||||
elif changing and not paths and steps == offsets[0][0]:
|
||||
paths.extend((notes, 'harmonic_adaptation of ' + identifier) for notes in adapt_path(pitches, spans, choices, plan.register))
|
||||
if all(p.number() % 12 in classes for p, classes in zip(pitches, allowed)):
|
||||
realized = [tuple(generator.solo.types.Note(start, end, pitch, role='path') for (start, end), pitch in zip(spans, pitches))]
|
||||
elif any(classes != allowed[0] for classes in allowed):
|
||||
realized = adapt_path(pitches, spans, choices, plan.register)
|
||||
identifier = 'harmonic_adaptation of ' + identifier
|
||||
else:
|
||||
continue
|
||||
for notes in realized:
|
||||
identity = tuple(n.pitch for n in notes)
|
||||
if identity not in identities:
|
||||
identities.add(identity)
|
||||
paths.append((notes, identifier))
|
||||
return paths
|
||||
|
||||
def candidates(cell, plan, choices, sequences):
|
||||
result = []
|
||||
target = plan.target if plan.target and cell.start <= plan.target.latest < cell.end else None
|
||||
local_plan = dataclasses.replace(plan, start=cell.start, end=cell.end, release=fractions.Fraction(), passages=tuple(dataclasses.replace(p, start=max(cell.start, p.start), end=min(cell.end, p.end)) for p in plan.passages if p.start < cell.end and p.end > cell.start))
|
||||
evaluation = generator.solo.scoring.Evaluation(local_plan, choices)
|
||||
for identifier, pattern in cell.patterns:
|
||||
spans = tuple((cell.start + start, cell.start + end) for start, end in pattern)
|
||||
active = tuple(i for i, (start, end) in enumerate(spans) if not any(c.scale is None and c.span.start < end and c.span.end > start for c in choices))
|
||||
local_cell = cell
|
||||
if len(active) != len(spans):
|
||||
if cell.motif and plan.treatment in ('quote', 'exact_quote'):
|
||||
continue
|
||||
spans = tuple(spans[i] for i in active)
|
||||
identifier += ':harmonic_fragment'
|
||||
if cell.motif:
|
||||
local_cell = dataclasses.replace(cell, motif=dataclasses.replace(cell.motif, notes=tuple(cell.motif.notes[i] for i in active)))
|
||||
references = tuple(cell.motif.notes[min(len(cell.motif.notes) - 1, i * len(cell.motif.notes) // len(pattern))] for i in active)
|
||||
local_cell = dataclasses.replace(cell, motif=dataclasses.replace(cell.motif, notes=references))
|
||||
paths = []
|
||||
if local_cell.motif and spans:
|
||||
if plan.treatment == 'quote' and ':fragment' not in identifier and ':harmonic_fragment' not in identifier and ':displaced' not in identifier:
|
||||
if plan.treatment in ('quote', 'exact_quote') and ':fragment' not in identifier and ':harmonic_fragment' not in identifier and ':displaced' not in identifier:
|
||||
for octaves in (0, 1, -1, 2, -2):
|
||||
notes = tuple(generator.solo.types.Note(start, end, dataclasses.replace(source.pitch, octave=source.pitch.octave + octaves), source.sources, 'quote') for (start, end), source in zip(spans, local_cell.motif.notes))
|
||||
if valid_notes(notes, choices):
|
||||
paths.append((notes, f'quote octave {octaves:+}', 0))
|
||||
for mismatch, notes in thematic_paths(local_cell, spans, choices, plan):
|
||||
treatment = 'fragment' if 'fragment' in identifier else 'rhythmic_displacement' if ':displaced' in identifier else 'changed_ending' if plan.relationship == 'answer' else 'harmonic_adaptation'
|
||||
paths.append((notes, treatment, 1 + mismatch))
|
||||
if plan.treatment not in ('quote', 'exact_quote'):
|
||||
for mismatch, notes in thematic_paths(local_cell, spans, choices, plan):
|
||||
treatment = 'fragment' if 'fragment' in identifier else plan.treatment
|
||||
paths.append((notes, treatment, mismatch))
|
||||
else:
|
||||
paths = [(notes, name, 0) for notes, name in sequence_paths(spans, choices, plan, sequences)]
|
||||
for notes, treatment, identity in paths:
|
||||
if not target_ok(notes, target):
|
||||
continue
|
||||
notes = tuple(dataclasses.replace(n, role='target' if target and target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches else n.role) for n in notes)
|
||||
register = 16 * sum(max(0, plan.register[0] - n.pitch.number(), n.pitch.number() - plan.register[1]) for n in notes)
|
||||
if notes:
|
||||
register += abs(max(n.pitch.number() for n in notes) - sum(plan.register) // 2)
|
||||
density = abs(len(notes) - (cell.end - cell.start) * plan.activity)
|
||||
contour = sum(max(0, abs(b.pitch.number() - a.pitch.number()) - 5) for a, b in zip(notes, notes[1:]))
|
||||
if not cell.motif and len({n.pitch.number() for n in notes}) == 1:
|
||||
contour += max(0, len(notes) - 2)
|
||||
rank = (identity, register, density if not cell.motif else 0, contour)
|
||||
result.append(Candidate(notes, identifier, treatment, rank))
|
||||
notes = tuple(dataclasses.replace(n, role='target' if n.role not in ('motif', 'quote') and target and target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches else n.role) for n in notes)
|
||||
features = evaluation.features(notes)
|
||||
fidelity = identity if cell.motif else 0
|
||||
features['fidelity'] = fidelity
|
||||
rank = (generator.solo.scoring.value(features),)
|
||||
result.append(Candidate(notes, identifier, treatment, rank, fidelity))
|
||||
# A no-harmony passage is intentional silence, not a failed pitch search.
|
||||
if not any(c.span.chord and c.span.start < cell.end and c.span.end > cell.start for c in choices):
|
||||
result = [Candidate((), 'no_harmony', 'space', (0, 0, 0, 0))]
|
||||
if not (cell.motif and plan.treatment in ('quote', 'exact_quote')) and not any(c.span.chord and c.span.start < cell.end and c.span.end > cell.start for c in choices):
|
||||
result = [Candidate((), 'no_harmony', 'space', (0,))]
|
||||
return sorted(result, key=lambda c: (c.rank, c.source, c.treatment, tuple(n.pitch for n in c.notes)))
|
||||
|
||||
def compose(plan, measures, choices, sequences, rhythms, tempo, previous=None):
|
||||
states = [((0, 0, 0, 0, 0, 0, 0), (), previous)]
|
||||
for cell in cells(plan, measures, rhythms):
|
||||
pool = candidates(cell, plan, choices, sequences)
|
||||
@dataclasses.dataclass
|
||||
class SearchContext:
|
||||
pools: dict = dataclasses.field(default_factory=dict)
|
||||
results: dict = dataclasses.field(default_factory=dict)
|
||||
evaluations: dict = dataclasses.field(default_factory=collections.OrderedDict)
|
||||
diagnostics: dict = dataclasses.field(default_factory=lambda: {'searches': 0, 'cache_hits': 0, 'generated_candidates': 0, 'evaluated_extensions': 0, 'peak_states': 0, 'candidate_families': {}, 'surviving_families': {}})
|
||||
|
||||
def note_family(notes):
|
||||
return generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))) if notes else 'space'
|
||||
|
||||
def state_identity(state, plan):
|
||||
_, decisions, last, _, notes = state
|
||||
ending = (last.pitch, last.position, last.start, last.end) if last else None
|
||||
times = tuple((float(n.start), float(n.end)) for n in notes)
|
||||
windows = tuple((float(p.start), float(p.end)) for p in generator.solo.scoring.passages(plan))
|
||||
activity = tuple((sum(start <= a < end for a, _ in times), sum(max(0, min(b, end) - max(a, start)) for a, b in times)) for start, end in windows)
|
||||
rhythm = tuple((n.start, n.end) for n in notes[-3:])
|
||||
progress = tuple(any(p.earliest <= n.start <= p.latest and p.low <= n.pitch.number() <= p.high for n in notes) for p in plan.landmarks)
|
||||
return ending, activity, rhythm, progress, tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes[-4:], notes[-4:][1:])), tuple(d.treatment for d in decisions)
|
||||
|
||||
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
|
||||
context = context if context is not None else SearchContext()
|
||||
key = (plan, previous)
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits'] += 1
|
||||
return context.results[key]
|
||||
context.diagnostics['searches'] += 1
|
||||
if plan not in context.pools:
|
||||
context.pools[plan] = tuple((cell, candidates(cell, plan, choices, sequences)) for cell in cells(plan, measures, rhythms))
|
||||
for _, pool in context.pools[plan]:
|
||||
context.diagnostics['generated_candidates'] += len(pool)
|
||||
for candidate in pool:
|
||||
family = note_family(candidate.notes)
|
||||
counts = context.diagnostics['candidate_families']
|
||||
counts[family] = counts.get(family, 0) + 1
|
||||
states = [(0.0, (), previous, 0, ())]
|
||||
for cell, pool in context.pools[plan]:
|
||||
evaluation = generator.solo.scoring.Evaluation(plan, choices, cell.end)
|
||||
following, extensions = [], {}
|
||||
for cost, decisions, last in states:
|
||||
for _, decisions, last, travel, sounded in states:
|
||||
target_met = target_ok(sounded, plan.target)
|
||||
|
||||
def extension_rank(candidate):
|
||||
jump = abs(candidate.notes[0].pitch.number() - last.pitch.number()) if candidate.notes and last else 0
|
||||
return candidate.rank, jump, candidate.source, candidate.treatment, tuple(n.pitch for n in candidate.notes)
|
||||
jump = abs(candidate.notes[0].pitch.number() - last.pitch.number()) / 12 if candidate.notes and last else 0
|
||||
return candidate.rank[0] + min(1, jump), candidate.source, candidate.treatment, tuple(n.pitch for n in candidate.notes)
|
||||
|
||||
key = last.pitch.number() if last else None
|
||||
if key not in extensions:
|
||||
extensions[key] = heapq.nsmallest(CELL_LIMIT, pool, key=extension_rank)
|
||||
for candidate in extensions[key]:
|
||||
placed = generator.solo.placement.place(candidate.notes, tempo, last)
|
||||
if placed is None:
|
||||
continue
|
||||
comfort, notes = placed
|
||||
jump = abs(notes[0].pitch.number() - last.pitch.number()) if notes and last else 0
|
||||
monotony = int(bool(notes and decisions and decisions[-1].notes) and tuple(n.pitch for n in notes) == tuple(n.pitch for n in decisions[-1].notes) and cell.motif is None)
|
||||
local = (*candidate.rank, jump, comfort, monotony)
|
||||
total = tuple(a + b for a, b in zip(cost, local))
|
||||
decision = generator.solo.types.Decision(cell.start, cell.end, candidate.source, candidate.treatment, notes)
|
||||
following.append((total, (*decisions, decision), notes[-1] if notes else last))
|
||||
extension_key = (last.pitch if last else None, target_met)
|
||||
if extension_key not in extensions:
|
||||
eligible = [c for c in pool if not plan.target or plan.target.latest >= cell.end and cell.end < plan.end or target_met or target_ok(c.notes, plan.target)]
|
||||
extensions[extension_key] = generator.solo.sequences.diverse(eligible, extension_rank, lambda c: (len(c.notes), note_family(c.notes), c.treatment if cell.motif else ''), CELL_LIMIT)
|
||||
for candidate in extensions[extension_key]:
|
||||
variants = [candidate.notes]
|
||||
passage = next((p for p in plan.passages if p.start <= cell.start < p.end), None)
|
||||
if cell.motif is None and last and last.end == cell.start and len(candidate.notes) == 1 and candidate.notes[0].start == cell.start and candidate.notes[0].pitch == last.pitch and passage and passage.intent == 'sustained_arrival':
|
||||
variants.append((dataclasses.replace(candidate.notes[0], continuation=True, role='sustain'),))
|
||||
for variant in variants:
|
||||
context.diagnostics['evaluated_extensions'] += 1
|
||||
placed = generator.solo.placement.place(variant, tempo, last)
|
||||
if placed is None:
|
||||
continue
|
||||
comfort, notes = placed
|
||||
decision = generator.solo.types.Decision(cell.start, cell.end, candidate.source, candidate.treatment, notes, candidate.fidelity)
|
||||
combined = (*decisions, decision)
|
||||
canonical = generator.solo.types.sounding_notes((*sounded, *notes))
|
||||
if plan.target and (plan.target.latest < cell.end or cell.end == plan.end) and not target_ok(canonical, plan.target):
|
||||
continue
|
||||
musical_key = (plan, cell.end, tuple((n.start, n.end, n.pitch, n.role) for n in canonical))
|
||||
if musical_key not in context.evaluations:
|
||||
context.evaluations[musical_key] = evaluation.features(canonical) if evaluation.feasible(canonical) else None
|
||||
if len(context.evaluations) > 32768:
|
||||
context.evaluations.popitem(last=False)
|
||||
else:
|
||||
context.evaluations.move_to_end(musical_key)
|
||||
evaluated = context.evaluations[musical_key]
|
||||
if evaluated is None:
|
||||
continue
|
||||
features = dict(evaluated)
|
||||
features['connection'] = min(1, abs(canonical[0].pitch.number() - previous.pitch.number()) / 12) if canonical and previous else 0
|
||||
features['movement'] = min(1, (travel + comfort) / max(1, len(canonical)) / 2000)
|
||||
total = generator.solo.scoring.value(features)
|
||||
following.append((total, combined, canonical[-1] if canonical else last, travel + comfort, canonical))
|
||||
if not following:
|
||||
raise SearchExhausted(f'{plan.id}, beats {cell.start + 1}-{cell.end + 1}: bounded solo search exhausted; no feasible phrase extension among {BEAM_WIDTH} states / {CELL_LIMIT} extensions (not proof that the passage is unplayable)')
|
||||
following.sort(key=lambda item: (item[0], tuple((d.source, d.treatment, tuple((n.pitch, n.position) for n in d.notes)) for d in item[1])))
|
||||
# Retain distinct endings for lookahead instead of 64 copies of one path.
|
||||
|
||||
def rank(state):
|
||||
return state[0], tuple((n.pitch, n.position, n.start, n.end) for n in state[4])
|
||||
|
||||
unique = {}
|
||||
for item in following:
|
||||
last = item[2]
|
||||
key = (last.pitch, last.position, last.end) if last else None
|
||||
unique.setdefault(key, item)
|
||||
states = list(unique.values())[:BEAM_WIDTH]
|
||||
_, decisions, _ = states[0]
|
||||
return generator.solo.types.Phrase(plan, decisions)
|
||||
for state in sorted(following, key=rank):
|
||||
unique.setdefault(state_identity(state, plan), state)
|
||||
states = list(generator.solo.sequences.diverse(unique.values(), rank, lambda s: (note_family(s[4]), len(s[4]), s[4][-1].pitch.number() if s[4] else None), BEAM_WIDTH))
|
||||
states.sort(key=rank)
|
||||
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
|
||||
selected = generator.solo.sequences.diverse(states, lambda s: (s[0], tuple(n.pitch for n in s[4])), lambda s: (note_family(s[4]), len(s[4]), s[4][-1].pitch if s[4] else None), generator.solo.policy.DEFAULT.alternatives)
|
||||
for state in selected:
|
||||
family = note_family(state[4])
|
||||
counts = context.diagnostics['surviving_families']
|
||||
counts[family] = counts.get(family, 0) + 1
|
||||
result = tuple(generator.solo.types.Phrase(plan, decisions, score) for score, decisions, _, _, _ in selected)
|
||||
context.results[key] = result
|
||||
return result
|
||||
|
||||
def compose(plan, measures, choices, sequences, rhythms, tempo, previous=None):
|
||||
return alternatives(plan, measures, choices, sequences, rhythms, tempo, previous)[0]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
@@ -13,9 +14,16 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
|
||||
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
|
||||
positioned = tuple(dataclasses.replace(c, span=dataclasses.replace(c.span, start=c.span.start + time_offset, end=c.span.end + time_offset, measure=c.span.measure + measure_offset)) for c in solo.choices)
|
||||
lines = [generator.scales.report.passage(solo.part, region, positioned, scales), f'Phrase composition: {solo.part}', f'Placement reference tempo: {solo.tempo} quarter notes/minute. Runtime tempo changes are not a comfort guarantee.', 'Times below are quarter-note offsets from the start of this solo; pitches are sounding.']
|
||||
if solo.diagnostics:
|
||||
lines.append('Composition diagnostics: ' + json.dumps(solo.diagnostics, sort_keys=True))
|
||||
for phrase in solo.phrases:
|
||||
plan = phrase.plan
|
||||
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
|
||||
lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}')
|
||||
for passage in plan.passages:
|
||||
lines.append(f' Passage {passage.start}-{passage.end}: {passage.intent}; attacks/beat {float(passage.activity):.3f}; occupancy {float(passage.occupancy):.2f}')
|
||||
for landmark in plan.landmarks:
|
||||
lines.append(f' Landmark {landmark.name}: {landmark.earliest}-{landmark.latest}; sounding MIDI {landmark.low}-{landmark.high}; direction {landmark.direction}')
|
||||
if plan.target:
|
||||
lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}')
|
||||
for decision in phrase.decisions:
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Duration-normalized musical evaluation, independent of search/notation chunks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import fractions
|
||||
|
||||
import generator.solo.policy
|
||||
import generator.solo.types
|
||||
|
||||
def passages(plan):
|
||||
if plan.passages:
|
||||
return plan.passages
|
||||
end = plan.end - plan.release
|
||||
active = (generator.solo.types.Passage(plan.start, end, 'active', plan.activity, fractions.Fraction(3, 4)),) if end > plan.start else ()
|
||||
return active + ((generator.solo.types.Passage(end, plan.end, 'space', fractions.Fraction(), fractions.Fraction()),) if plan.release else ())
|
||||
|
||||
def intervals(choices, start, end):
|
||||
return tuple((max(start, c.span.start), min(end, c.span.end)) for c in choices if c.scale and c.span.start < end and c.span.end > start)
|
||||
|
||||
def overlap(start, end, intervals):
|
||||
return sum((max(0, min(end, b) - max(start, a)) for a, b in intervals), fractions.Fraction())
|
||||
|
||||
def corridor(pitch, low, high):
|
||||
return max(low - pitch, 0, pitch - high)
|
||||
|
||||
def motif_fidelity(notes, motif, treatment):
|
||||
if treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
|
||||
return 0.0
|
||||
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
|
||||
expected = motif.contour()
|
||||
if not actual or not expected:
|
||||
return 0.0 if len(notes) >= min(1, len(motif.notes)) else 1.0
|
||||
count = min(2, len(actual), len(expected)) if treatment == 'opening_reference' else max(1, len(actual) // 2) if treatment == 'answer' else len(actual)
|
||||
errors = []
|
||||
for index in range(count):
|
||||
reference = expected[min(len(expected) - 1, index * len(expected) // max(1, len(actual)))]
|
||||
motion = actual[index]
|
||||
direction = ((reference > 0) - (reference < 0)) != ((motion > 0) - (motion < 0))
|
||||
errors.append(0.75 * direction + 0.25 * min(1, abs(motion - reference) / 12))
|
||||
return sum(errors) / len(errors)
|
||||
|
||||
class Evaluation:
|
||||
"""Prepare fixed harmonic windows once; only soft penalties use float time."""
|
||||
def __init__(self, plan, choices, until=None):
|
||||
self.plan = plan
|
||||
self.until = plan.end if until is None else until
|
||||
self.duration = max(float(self.until - plan.start), 0.001)
|
||||
self.windows = []
|
||||
self.required = []
|
||||
for passage in passages(plan):
|
||||
end = min(passage.end, self.until)
|
||||
if end <= passage.start:
|
||||
continue
|
||||
active = intervals(choices, passage.start, end)
|
||||
length = float(sum((b - a for a, b in active), fractions.Fraction()))
|
||||
if length:
|
||||
self.windows.append((float(passage.start), float(end), tuple((float(a), float(b)) for a, b in active), length, float(passage.activity) * length, float(passage.occupancy), 0.5 if self.until < passage.end else 0))
|
||||
if passage.end <= self.until:
|
||||
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference') and passage.end <= plan.start + plan.motif.duration
|
||||
authored_silence = protected and not any(overlap(plan.start + n.start, plan.start + n.end, active) for n in plan.motif.notes)
|
||||
self.required.append((passage, active, authored_silence))
|
||||
self.landmarks = tuple((float(p.earliest), float(p.latest), p.low, p.high, p.direction) for p in plan.landmarks if p.latest <= self.until)
|
||||
def feasible(self, notes):
|
||||
# Hard silence/space requirements retain exact rational boundaries.
|
||||
for passage, active, authored_silence in self.required:
|
||||
if passage.intent == 'space':
|
||||
if any(n.start < passage.end and n.end > passage.start for n in notes):
|
||||
return False
|
||||
elif active and not authored_silence and not any(n.start < b and n.end > a for n in notes for a, b in active):
|
||||
return False
|
||||
return True
|
||||
def features(self, notes, *, previous=None, movement=0):
|
||||
notes = generator.solo.types.sounding_notes(notes)
|
||||
sounded = tuple((float(n.start), float(n.end), n.pitch.number()) for n in notes)
|
||||
activity, occupancy = 0.0, 0.0
|
||||
for start, end, active, length, expected, occupied_goal, tolerance in self.windows:
|
||||
attacks = sum(start <= a < end and any(x <= a < y for x, y in active) for a, _, _ in sounded)
|
||||
occupied = sum(max(0, min(b, y) - max(a, x)) for a, b, _ in sounded for x, y in active)
|
||||
deviation = max(0, abs(attacks - expected) - tolerance)
|
||||
activity += length * min(1, deviation / max(1, expected))
|
||||
occupancy += length * abs(occupied / length - occupied_goal)
|
||||
start, end = float(self.plan.start), float(self.until)
|
||||
durations = [max(0, min(b, end) - max(a, start)) for a, b, _ in sounded]
|
||||
register = sum(length * min(1, corridor(pitch, *self.plan.register) / 12) for (_, _, pitch), length in zip(sounded, durations)) / max(sum(durations), 0.001)
|
||||
motions = [b[2] - a[2] for a, b in zip(sounded, sounded[1:])]
|
||||
contour = sum(min(1, max(0, abs(m) - 5) / 12) for m in motions) / max(1, len(motions))
|
||||
repetition = sum(a == b == 0 for a, b in zip(motions, motions[1:])) / max(1, len(motions) - 1)
|
||||
landmarks = []
|
||||
for earliest, latest, low, high, direction in self.landmarks:
|
||||
prior = next((n for n in reversed(sounded) if n[0] < earliest), None)
|
||||
landmarks.append(min((0.75 * min(1, corridor(n[2], low, high) / 12) + 0.25 * bool(prior and direction and (n[2] - prior[2]) * direction <= 0) for n in sounded if earliest <= n[0] <= latest), default=1))
|
||||
motif_notes = tuple(n for n in notes if n.role in ('motif', 'quote'))
|
||||
connection = min(1, abs(sounded[0][2] - previous.pitch.number()) / 12) if sounded and previous else 0
|
||||
return {'activity': activity / self.duration, 'occupancy': occupancy / self.duration, 'register': register, 'contour': contour, 'repetition': repetition, 'landmarks': sum(landmarks) / max(1, len(landmarks)), 'connection': connection, 'movement': min(1, movement / max(1, len(notes)) / 2000), 'fidelity': motif_fidelity(motif_notes, self.plan.motif, self.plan.treatment) if motif_notes else 0}
|
||||
|
||||
def features(plan, notes, choices, *, until=None, previous=None, movement=0):
|
||||
return Evaluation(plan, choices, until).features(notes, previous=previous, movement=movement)
|
||||
|
||||
def value(features):
|
||||
return sum(weight * features.get(name, 0) for name, weight in generator.solo.policy.DEFAULT.weights)
|
||||
|
||||
def feasible(plan, notes, choices, until):
|
||||
return Evaluation(plan, choices, until).feasible(notes)
|
||||
@@ -4,6 +4,8 @@ import dataclasses
|
||||
import pathlib
|
||||
import types
|
||||
|
||||
import generator.solo.policy
|
||||
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
|
||||
@@ -20,9 +22,46 @@ class Sequence:
|
||||
offsets = self.offsets()
|
||||
return max(map(abs, self.steps)), max(offsets) - min(offsets), sum(a * b < 0 for a, b in zip(self.steps, self.steps[1:])), self.id
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Path:
|
||||
steps: tuple[int, ...]
|
||||
sources: tuple[str, ...]
|
||||
def offsets(self):
|
||||
result = [0]
|
||||
for step in self.steps:
|
||||
result.append(result[-1] + step)
|
||||
return tuple(result)
|
||||
|
||||
def family(steps):
|
||||
if not steps or all(s == 0 for s in steps):
|
||||
return 'repeated'
|
||||
turns = sum(a * b < 0 for a, b in zip(steps, steps[1:]))
|
||||
if not turns:
|
||||
return 'rising' if sum(steps) > 0 else 'falling'
|
||||
if max(map(abs, steps)) >= 4:
|
||||
return 'leap_return'
|
||||
if sum(steps) == 0 and max(map(abs, steps)) == 1:
|
||||
return 'neighbor'
|
||||
return ('arch' if steps[0] > 0 else 'valley') if turns == 1 else 'wave'
|
||||
|
||||
def diverse(items, rank, category, limit):
|
||||
"""Round-robin eligible musical families before a destructive cutoff."""
|
||||
groups = {}
|
||||
for item in sorted(items, key=rank):
|
||||
groups.setdefault(category(item), []).append(item)
|
||||
result, index = [], 0
|
||||
while len(result) < limit:
|
||||
layer = [values[index] for values in groups.values() if len(values) > index]
|
||||
if not layer:
|
||||
break
|
||||
result.extend(sorted(layer, key=rank)[:limit - len(result)])
|
||||
index += 1
|
||||
return tuple(result)
|
||||
|
||||
@dataclasses.dataclass(frozen=True, init=False)
|
||||
class Library:
|
||||
paths: types.MappingProxyType
|
||||
chains: dict
|
||||
def __init__(self, sequences: tuple[Sequence, ...]):
|
||||
paths, identities = {}, set()
|
||||
for sequence in sequences:
|
||||
@@ -31,8 +70,44 @@ class Library:
|
||||
identities.add(sequence.id)
|
||||
paths.setdefault(len(sequence.steps) + 1, []).append(sequence)
|
||||
object.__setattr__(self, 'paths', types.MappingProxyType({count: tuple(sorted(items, key=lambda s: s.rank())) for count, items in paths.items()}))
|
||||
object.__setattr__(self, 'chains', {})
|
||||
def connecting(self, count: int, displacement: int | None = None) -> tuple[Sequence, ...]:
|
||||
return tuple(s for s in self.paths.get(count, ()) if displacement is None or s.offsets()[-1] == displacement)
|
||||
def chained(self, count):
|
||||
return self.retrieve(count)
|
||||
def retrieve(self, count, displacement=0, direction=0):
|
||||
key = (count, displacement, direction)
|
||||
if key in self.chains:
|
||||
return self.chains[key]
|
||||
if not 1 <= count <= 16:
|
||||
return ()
|
||||
states, remaining = (Path((), ()),), count - 1
|
||||
while remaining:
|
||||
size = min(3, remaining)
|
||||
options = self.connecting(size + 1)
|
||||
if not options:
|
||||
size = next((n for n in range(size - 1, 0, -1) if self.connecting(n + 1)), 0)
|
||||
if not size:
|
||||
return ()
|
||||
options = self.connecting(size + 1)
|
||||
desired = displacement * (count - 1 - remaining + size) / max(1, count - 1)
|
||||
def rank(path):
|
||||
offsets = path.offsets()
|
||||
motion = offsets[-1]
|
||||
return abs(motion - desired) + (2 if direction and motion * direction < 0 else 0), max(offsets) - min(offsets), max(map(abs, path.steps), default=0), path.sources
|
||||
# Retrieve from the entire authored vocabulary, conditioned on each prefix.
|
||||
following = []
|
||||
for prior in states:
|
||||
if count > 4:
|
||||
local = self.retrieve(size + 1, round(desired - prior.offsets()[-1]), direction)
|
||||
paths = (Path(prior.steps + s.steps, prior.sources + s.sources) for s in local)
|
||||
else:
|
||||
paths = (Path(prior.steps + s.steps, prior.sources + (s.id,)) for s in options)
|
||||
following.extend(diverse(paths, rank, lambda p: family(p.steps), generator.solo.policy.DEFAULT.retrieval))
|
||||
states = diverse(following, rank, lambda p: (family(p.steps), (p.offsets()[-1] > 0) - (p.offsets()[-1] < 0)), generator.solo.policy.DEFAULT.retrieval)
|
||||
remaining -= size
|
||||
self.chains[key] = states
|
||||
return states
|
||||
|
||||
def read(path: pathlib.Path, identifier: str, count: int) -> Sequence:
|
||||
document = generator.musicxml.load_musicxml(path)
|
||||
|
||||
+39
-1
@@ -32,6 +32,19 @@ class Note:
|
||||
sources: tuple[str, ...] = ()
|
||||
role: str = 'continuation'
|
||||
position: generator.fretboard.TabPosition | None = None
|
||||
continuation: bool = False
|
||||
|
||||
def sounding_notes(notes):
|
||||
"""Collapse explicitly continued attacks, never adjacent repeated pitches."""
|
||||
result = []
|
||||
for note in notes:
|
||||
if note.continuation:
|
||||
if not result or result[-1].end != note.start or result[-1].pitch != note.pitch or result[-1].position != note.position:
|
||||
raise ValueError('solo continuation requires the same contiguous pitch and guitar position')
|
||||
result[-1] = dataclasses.replace(result[-1], end=note.end)
|
||||
else:
|
||||
result.append(note)
|
||||
return tuple(result)
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Motif:
|
||||
@@ -51,6 +64,23 @@ class TargetEvent:
|
||||
chord: str
|
||||
reason: str
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Passage:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
intent: str
|
||||
activity: fractions.Fraction
|
||||
occupancy: fractions.Fraction
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Landmark:
|
||||
earliest: fractions.Fraction
|
||||
latest: fractions.Fraction
|
||||
low: int
|
||||
high: int
|
||||
direction: int = 0
|
||||
name: str = 'arrival'
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class PhrasePlan:
|
||||
id: str
|
||||
@@ -63,6 +93,11 @@ class PhrasePlan:
|
||||
register: tuple[int, int]
|
||||
activity: fractions.Fraction
|
||||
release: fractions.Fraction = fractions.Fraction(1, 2)
|
||||
passages: tuple[Passage, ...] = ()
|
||||
landmarks: tuple[Landmark, ...] = ()
|
||||
ending: str = 'breath'
|
||||
closure: str = 'closed'
|
||||
seed: str = ''
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Decision:
|
||||
@@ -71,11 +106,13 @@ class Decision:
|
||||
source: str
|
||||
treatment: str
|
||||
notes: tuple[Note, ...]
|
||||
fidelity: float = 0
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Phrase:
|
||||
plan: PhrasePlan
|
||||
decisions: tuple[Decision, ...]
|
||||
score: float = 0
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Solo:
|
||||
@@ -84,8 +121,9 @@ class Solo:
|
||||
phrases: tuple[Phrase, ...]
|
||||
tempo: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
|
||||
def notes(self) -> tuple[Note, ...]:
|
||||
return tuple(n for p in self.phrases for d in p.decisions for n in d.notes)
|
||||
return sounding_notes(n for p in self.phrases for d in p.decisions for n in d.notes)
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def scale_pitch(scale: generator.scales.library.Scale, index: int, octave: int = 4) -> Pitch:
|
||||
|
||||
Reference in New Issue
Block a user