Soloing
ci / checks (push) Successful in 1h9m55s

This commit is contained in:
khodowany
2026-10-01 08:35:12 -05:00
parent eae432aa83
commit 75655786e9
16 changed files with 1015 additions and 161 deletions
+11
View File
@@ -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 = [], {}
+3 -1
View File
@@ -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)
+210
View File
@@ -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
View File
@@ -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')
+8 -2
View File
@@ -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
View File
@@ -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)
+28
View File
@@ -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
View File
@@ -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]
+8
View File
@@ -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:
+102
View File
@@ -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)
+75
View File
@@ -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
View File
@@ -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: