@@ -85,7 +85,7 @@ def backing(baseline, foreground, part, rhythms, references, choices, initial=No
|
||||
generator.model.validate_document(document)
|
||||
return document['staves'][0]['measures']
|
||||
|
||||
def apply(source, parts, *, rhythms, scales, sequences, solos=None):
|
||||
def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None):
|
||||
"""Compose once per song, replacing only already-placed solo section windows."""
|
||||
analysis = generator.solo.analysis.analyze(source)
|
||||
sections = regions(source)
|
||||
@@ -103,7 +103,7 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None):
|
||||
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']]
|
||||
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)
|
||||
solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial, revise=revise, context=(contexts or {}).get(part))
|
||||
if solos is not None:
|
||||
solos.append(solo)
|
||||
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
|
||||
|
||||
+88
-10
@@ -5,6 +5,7 @@ import argparse
|
||||
import array
|
||||
import collections
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import hashlib
|
||||
import html
|
||||
@@ -26,6 +27,7 @@ import generator.scales.library
|
||||
import generator.solo.arrangement
|
||||
import generator.solo.report
|
||||
import generator.solo.sequences
|
||||
import generator.solo.realization
|
||||
|
||||
STAGES = ('baseline', 'A', 'B', 'C', 'D')
|
||||
|
||||
@@ -71,13 +73,14 @@ def fingerprint():
|
||||
digest.update(path.read_bytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
def capture(root, stage):
|
||||
def resources():
|
||||
return (generator.rhythm.load_library(pathlib.Path('data/rhythms')), generator.scales.library.load_library(pathlib.Path('data/scales')), generator.solo.sequences.load_library(pathlib.Path('data/sequences')))
|
||||
|
||||
def capture(root, stage, libraries=None, contexts=None):
|
||||
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'))
|
||||
rhythms, scales, sequences = libraries or resources()
|
||||
result = {'stage': stage, 'implementation': fingerprint(), 'songs': {}}
|
||||
try:
|
||||
result['policy'] = importlib.import_module('generator.solo.policy').describe()
|
||||
@@ -89,12 +92,37 @@ def capture(root, stage):
|
||||
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)
|
||||
shared = contexts.setdefault(path.stem, {p: generator.solo.realization.SearchContext() for p in ('acoustic', 'electric')}) if contexts is not None else None
|
||||
if shared:
|
||||
for context in shared.values():
|
||||
context.diagnostics = generator.solo.realization.SearchContext().diagnostics
|
||||
generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared)
|
||||
song = {'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'parts': parts, 'solos': {}, 'elapsed_seconds': time.monotonic() - started}
|
||||
regions = generator.solo.arrangement.regions(document)
|
||||
for solo, index in zip(solos, (3, 4)):
|
||||
first, count, offset, length = regions[index]
|
||||
song['solos'][solo.part] = {'first': first, 'count': count, 'offset': str(offset), 'length': str(length), 'tempo': str(solo.tempo), 'metrics': metrics(solo), 'report': generator.solo.report.describe(solo, scales), 'structure': generator.solo.report.structure(solo)}
|
||||
drafts = []
|
||||
template = parts[solo.part]['staves'][0]['measures'][first:first + count]
|
||||
boundaries = [fractions.Fraction()]
|
||||
for measure in template:
|
||||
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
|
||||
for phrase in solo.phrases:
|
||||
if not phrase.draft:
|
||||
continue
|
||||
begin = boundaries.index(phrase.plan.start)
|
||||
stop = next(i for i, boundary in enumerate(boundaries) if boundary >= phrase.plan.end)
|
||||
view = excerpt(parts[solo.part], first + begin, stop - begin)
|
||||
local = tuple(dataclasses.replace(n, start=n.start - phrase.plan.start, end=n.end - phrase.plan.start, continuation=False if i == 0 else n.continuation) for i, n in enumerate(phrase.draft.notes))
|
||||
placed = generator.solo.placement.place(local, solo.tempo)
|
||||
if placed is None:
|
||||
raise ValueError(f'{phrase.plan.id}: draft cannot be placed for independent audition')
|
||||
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], 'draft_' + phrase.plan.id)
|
||||
generator.solo.notation.replace_region(view, 0, foreground)
|
||||
generator.model.validate_document(view)
|
||||
generator.fretboard.validate_document(view)
|
||||
drafts.append({'id': phrase.plan.id, 'draft_id': phrase.draft.id, 'first': begin, 'count': stop - begin, 'document': view})
|
||||
song['solos'][solo.part]['draft_documents'] = drafts
|
||||
print(path.stem, solo.part, json.dumps(song['solos'][solo.part]['metrics']), flush=True)
|
||||
result['songs'][path.stem] = song
|
||||
destination.mkdir(parents=True)
|
||||
@@ -152,7 +180,7 @@ def mix(documents, tempo, root, cache):
|
||||
return output
|
||||
|
||||
def render(root):
|
||||
stages = ('before', 'after') if (root / 'before' / 'snapshot.json').exists() else STAGES
|
||||
stages = ('baseline', 'phrase', 'revised') if (root / 'revised' / 'snapshot.json').exists() else ('before', 'after') if (root / 'before' / 'snapshot.json').exists() else STAGES
|
||||
snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in stages}
|
||||
cache, clips, manifest = {}, [], {'sample_rate': generator.playback.SAMPLE_RATE, 'stages': {}, 'clips': []}
|
||||
for stage, snapshot in snapshots.items():
|
||||
@@ -163,7 +191,7 @@ def render(root):
|
||||
raise ValueError(f'{song_name}: source changed between audition stages')
|
||||
for part, solo in song['solos'].items():
|
||||
other = 'electric' if part == 'acoustic' else 'acoustic'
|
||||
windows = [('full', 0, solo['count']), ('opening8', 0, min(8, solo['count'])), ('closing4', max(0, solo['count'] - 4), min(4, solo['count']))] if stages[0] == 'before' else [('', 0, min(8, solo['count']))]
|
||||
windows = [('full', 0, solo['count']), ('opening8', 0, min(8, solo['count'])), ('closing4', max(0, solo['count'] - 4), min(4, solo['count']))] if stages[0] == 'before' or stages[-1] == 'revised' else [('', 0, min(8, solo['count']))]
|
||||
for label, offset, count in windows:
|
||||
first = solo['first'] + offset
|
||||
foreground = excerpt(song['parts'][part], first, count)
|
||||
@@ -179,6 +207,24 @@ def render(root):
|
||||
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
|
||||
clips.append((path, samples))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'window': label, 'first': offset, 'count': count, 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
|
||||
for draft in solo.get('draft_documents', ()):
|
||||
prefix = root / stage / f'{song_name}_{part}_draft_{draft["id"]}'
|
||||
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), draft['document'])
|
||||
if draft['first'] < 8:
|
||||
backing = excerpt(baseline['parts'][other], solo['first'] + draft['first'], draft['count'])
|
||||
path = prefix.with_suffix('.wav')
|
||||
clips.append((path, mix((draft['document'], backing), fractions.Fraction(solo['tempo']), root, cache)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'simple_phrase_draft', 'draft_id': draft['draft_id'], 'first': draft['first'], 'count': draft['count']})
|
||||
if stage in ('phrase', 'revised'):
|
||||
neutral = excerpt(song['parts'][part], solo['first'], min(8, solo['count']))
|
||||
for measure in neutral['staves'][0]['measures']:
|
||||
for voice in measure['voices']:
|
||||
for event in voice['notes']:
|
||||
for pitch in event['pitches']:
|
||||
pitch.update({'letter': 'c', 'alter': generator.model.rational(0), 'octave': 5, 'tablature': {'string': 2, 'fret': 1}})
|
||||
path = root / stage / f'{song_name}_{part}_rhythm_only.wav'
|
||||
clips.append((path, mix((neutral,), fractions.Fraction(solo['tempo']), root, cache)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'rhythm_only_diagnostic', 'valid_harmonic_solo': False})
|
||||
# One common attenuation preserves relative loudness and identical backing.
|
||||
peak = max(abs(value) for _, samples in clips for value in samples)
|
||||
gain = min(1, 0.95 / peak) if peak else 1
|
||||
@@ -186,9 +232,33 @@ def render(root):
|
||||
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_passage_index(root, snapshots) if stages[0] == 'before' else write_index(root, snapshots)
|
||||
write_phrase_index(root, snapshots) if stages[-1] == 'revised' else write_passage_index(root, snapshots) if stages[0] == 'before' else write_index(root, snapshots)
|
||||
print(f'Rendered {len(clips)} comparisons under {root}', flush=True)
|
||||
|
||||
def write_phrase_index(root, snapshots):
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Melodic phrase comparisons</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin-bottom:2rem}td,th{text-align:left;padding:.7rem;border-bottom:1px solid #ccd1c9}audio{width:250px}small{display:block}</style><h1>Melodic phrase comparisons</h1><p>Compare the current baseline, complete phrase construction, and phrase construction with two-sided join revision. Tempo, scale policy, activity targets and seed inputs are unchanged. Fixed mixes use baseline accompaniment and one shared gain. Revised mixes also include the normal responsive backing.</p><p>Draft pools are shared when incoming musical states match; earlier edits can legitimately change later incoming states. Pool fingerprints are recorded in the reports. Simple drafts are auditioned individually, with a held entrance restarted at the excerpt boundary. Rhythm-only clips use a neutral pitch and are not valid harmonic solos.</p><p>Listen for one recognizable idea developing toward a prepared destination. Metrics do not establish musical improvement. <a href="manifest.json">Manifest and diagnostics</a>.</p>']
|
||||
for name, song in snapshots['revised']['songs'].items():
|
||||
for part in song['solos']:
|
||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2>')
|
||||
body.append('<table><tr><th>Excerpt</th><th>Baseline</th><th>Phrase construction</th><th>With join revision</th></tr>')
|
||||
for window in ('opening8', 'full', 'closing4'):
|
||||
body.append(f'<tr><th>{window}</th>')
|
||||
for stage in ('baseline', 'phrase', 'revised'):
|
||||
prefix = f'{stage}/{name}_{part}_{window}'
|
||||
body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a> · <a href="{prefix}.json">Structure</a></small></td>')
|
||||
body.append('</tr>')
|
||||
body.append('</table>')
|
||||
for stage in ('phrase', 'revised'):
|
||||
detail = snapshots[stage]['songs'][name]['solos'][part]
|
||||
metric = detail['metrics']
|
||||
body.append(f'<p>{stage}: {metric["attacks_per_four_beats"]:.2f} attacks / four beats; {metric["sounding_occupancy"]:.1%} occupancy. Rhythm only: <audio controls preload="none" src="{stage}/{name}_{part}_rhythm_only.wav"></audio></p>')
|
||||
for draft in detail.get('draft_documents', ()):
|
||||
if draft['first'] < 8:
|
||||
prefix = f'{stage}/{name}_{part}_draft_{draft["id"]}'
|
||||
body.append(f'<p>Simple draft {draft["id"]}: <audio controls preload="none" src="{prefix}.wav"></audio> <a href="{prefix}.musicxml">MusicXML</a></p>')
|
||||
body.append(f'<p>Complete solo with responsive backing: <audio controls preload="none" src="revised/{name}_{part}_full_responsive.wav"></audio></p>')
|
||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||
|
||||
def write_index(root, snapshots):
|
||||
labels = {'baseline': 'Original engine', 'A': 'Scoring repair', 'B': 'Activity shapes', 'C': 'Motifs and retrieval', 'D': 'Phrase lookahead'}
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Solo comparisons</title><style>body{font:16px system-ui;max-width:1200px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:1rem;border-bottom:1px solid #ccd1c9}audio{width:280px}small{display:block;margin-top:.5rem}</style><h1>Solo comparisons</h1><p>Eight bars from the same solo entrance at the source tempo. All stages use the original backing and one shared mix gain. D also includes the normal backing that responds to the new solo.</p><p>A: scoring and register. B: activity and breathing. C: transformation contracts, contextual motifs and diverse paths. D: one-phrase lookahead. Expressive playback is deferred.</p><p>Metrics below describe the complete solo, not just the excerpt. These are diagnostics; listening and playing determine musical quality. <a href="manifest.json">Build fingerprints and diagnostics</a>.</p>']
|
||||
@@ -225,10 +295,18 @@ def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_auditions'))
|
||||
action = parser.add_mutually_exclusive_group(required=True)
|
||||
action.add_argument('--capture', choices=(*STAGES, 'before', 'after'))
|
||||
action.add_argument('--capture', choices=(*STAGES, 'before', 'after', 'phrase', 'revised'))
|
||||
action.add_argument('--compare-phrases', action='store_true', help='capture both new stages using shared concrete draft pools')
|
||||
action.add_argument('--render', action='store_true')
|
||||
args = parser.parse_args()
|
||||
capture(args.output, args.capture) if args.capture else render(args.output)
|
||||
if args.compare_phrases:
|
||||
libraries, contexts = resources(), {}
|
||||
capture(args.output, 'phrase', libraries, contexts)
|
||||
capture(args.output, 'revised', libraries, contexts)
|
||||
elif args.capture:
|
||||
capture(args.output, args.capture)
|
||||
else:
|
||||
render(args.output)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
+16
-18
@@ -15,7 +15,7 @@ import generator.solo.policy
|
||||
import generator.solo.realization
|
||||
import generator.solo.types
|
||||
|
||||
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None):
|
||||
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None):
|
||||
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
|
||||
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
|
||||
choices = generator.scales.selection.select(timeline, region, scales)
|
||||
@@ -29,27 +29,21 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
|
||||
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()
|
||||
context = context if context is not None else generator.solo.realization.SearchContext()
|
||||
context.revise = revise
|
||||
|
||||
def realize(plan, previous):
|
||||
original = plan
|
||||
for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1):
|
||||
context.batch = attempt
|
||||
try:
|
||||
options = generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context)
|
||||
if attempt:
|
||||
context.results[(original, previous)] = options
|
||||
return options
|
||||
except generator.solo.realization.SearchExhausted as error:
|
||||
if attempt == generator.solo.policy.DEFAULT.replan_attempts or plan.treatment in ('quote', 'exact_quote'):
|
||||
raise
|
||||
context.diagnostics.setdefault('replanning', []).append({'phrase': plan.id, 'attempt': attempt + 1, 'reason': str(error), 'adjustment': 'contour reference' if attempt == 0 else 'opening reference'})
|
||||
# Replan only ordinary development contracts; harmony, anchor pitches,
|
||||
# required holds, timing, activity and protected quotations stay fixed.
|
||||
treatment = 'contour_reference' if attempt == 0 else 'opening_reference'
|
||||
gestures = tuple(dataclasses.replace(g, treatment=treatment) if g.relationship not in ('statement', 'close') else g for g in plan.gestures)
|
||||
plan = dataclasses.replace(plan, gestures=gestures)
|
||||
context.diagnostics.setdefault('replanning', []).append({'phrase': plan.id, 'attempt': attempt + 1, 'reason': str(error), 'adjustment': 'next diverse complete rhythm and anchor constructions; contracts unchanged'})
|
||||
|
||||
phrases = choose_phrases(plans, realize)
|
||||
phrases = choose_phrases(plans, realize, lambda first, second, incoming: generator.solo.realization.revise_pair(first, second, incoming, choices, sequences, analysis.tempo, context))
|
||||
context.diagnostics['policy'] = generator.solo.policy.describe()['fingerprint']
|
||||
context.diagnostics['musical_identity'] = generator.solo.analysis.identity(analysis, measures, part)
|
||||
passage_ids = tuple(dict.fromkeys(p.passage_id for p in plans if p.passage_id))
|
||||
@@ -77,18 +71,20 @@ def transition_cost(first, second):
|
||||
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):
|
||||
def choose_phrases(plans, realize, revise_pair=None):
|
||||
"""Commit only after the next phrase has been evaluated from each ending."""
|
||||
result, previous = [], None
|
||||
result, previous, pending = [], None, None
|
||||
for index, plan in enumerate(plans):
|
||||
options = realize(plan, previous)
|
||||
if pending is not None and pending not in options:
|
||||
options = (pending, *options)
|
||||
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))
|
||||
ranked.append((option.score + incoming, option, None))
|
||||
continue
|
||||
try:
|
||||
following = realize(plans[index + 1], option.state or last)
|
||||
@@ -98,11 +94,13 @@ def choose_phrases(plans, realize):
|
||||
length = float(option.plan.end - option.plan.start)
|
||||
next_length = float(plans[index + 1].end - plans[index + 1].start)
|
||||
shared_passage = bool(plan.passage_id and plan.passage_id == plans[index + 1].passage_id)
|
||||
cost = min((future.score if shared_passage else (option.score * length + future.score * next_length) / (length + next_length)) + transition_cost(option, future) for future in following)
|
||||
ranked.append((cost + incoming, option))
|
||||
for future in following:
|
||||
left, right = revise_pair(option, future, previous) if revise_pair else (option, future)
|
||||
cost = (right.score if shared_passage else (left.score * length + right.score * next_length) / (length + next_length)) + transition_cost(left, right)
|
||||
ranked.append((cost + incoming, left, right))
|
||||
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]))))
|
||||
_, chosen, pending = 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 chosen.state:
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Phrase-wide rhythm construction and contracts for concrete melodic drafts.
|
||||
|
||||
No bar winners are assembled here. A rhythm is stated, transformed in place,
|
||||
and timed to its destinations before the pitch search begins.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
|
||||
import generator.model
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.policy
|
||||
import generator.solo.scoring
|
||||
import generator.solo.sequences
|
||||
import generator.solo.types
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Slot:
|
||||
id: str
|
||||
start: F
|
||||
end: F
|
||||
group: int
|
||||
reference: int
|
||||
forced: generator.solo.types.Pitch | None = None
|
||||
protected: bool = False
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Group:
|
||||
id: str
|
||||
operation: str
|
||||
start: F
|
||||
end: F
|
||||
source: str
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RhythmDraft:
|
||||
id: str
|
||||
slots: tuple[Slot, ...]
|
||||
groups: tuple[Group, ...]
|
||||
anchors: tuple[generator.solo.types.Anchor, ...]
|
||||
reference: generator.solo.types.Motif | generator.solo.types.RealizedMotif
|
||||
relationship: str
|
||||
octave: int = 0
|
||||
|
||||
def catalog(library, meter):
|
||||
"""Read complete snippets; ties across authored bars stay one attack."""
|
||||
result = {}
|
||||
for snippet in library.for_meter(meter):
|
||||
offset, spans = F(), []
|
||||
for bar in snippet.bars:
|
||||
for event in bar.events:
|
||||
if not event.sounding:
|
||||
continue
|
||||
start, end = offset + event.onset, offset + event.onset + event.duration
|
||||
if event.continuation:
|
||||
if spans and spans[-1][1] == start:
|
||||
spans[-1] = (spans[-1][0], end)
|
||||
else:
|
||||
spans.append((start, end))
|
||||
offset += bar.length
|
||||
if 2 <= len(spans) <= 12:
|
||||
result.setdefault(tuple(spans), (snippet.id, offset))
|
||||
return tuple((identifier, spans, length) for spans, (identifier, length) in result.items())
|
||||
|
||||
def templates(plan, reference, library, meter):
|
||||
source = tuple((n.start, n.end) for n in reference.notes)
|
||||
if plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
|
||||
return tuple([(reference.id, source, reference.duration)])
|
||||
# A compact identifying portion, including its authored internal rests.
|
||||
source = source[:6]
|
||||
result = []
|
||||
if source:
|
||||
length = min(reference.duration, source[-1][1] + F(1, 2))
|
||||
result.append((reference.id, source, length))
|
||||
choices = catalog(library, meter)
|
||||
ranked = generator.solo.sequences.diverse(choices,
|
||||
lambda item: (abs(len(item[1]) / float(item[2]) - float(plan.activity)), abs(sum(float(b - a) for a, b in item[1]) / float(item[2]) - 0.75), item[0]),
|
||||
lambda item: (len(item[1]), bool(item[1][0][0]), item[2]), 4)
|
||||
result.extend(ranked)
|
||||
return tuple(result)
|
||||
|
||||
def reopen(slots, cursor):
|
||||
"""An extension changes the previous provisional ending, not its core."""
|
||||
if not slots:
|
||||
return slots, cursor
|
||||
last = slots[-1]
|
||||
if last.protected or last.forced:
|
||||
return slots, cursor
|
||||
end = max(last.start + F(1, 2), last.end - F(1, 2))
|
||||
return (*slots[:-1], dataclasses.replace(last, end=end)), end
|
||||
|
||||
def develop_timing(pattern, period, unit, occupancy, connect):
|
||||
"""Diminish/broaden one established rhythm, retaining order and rests.
|
||||
|
||||
Ordinary diminution stops at eighth notes. Connected development may
|
||||
broaden existing sounds into part of their following rest, not insert a new
|
||||
independent pattern. These choices precede pitch/harmonic realization.
|
||||
"""
|
||||
result, cursor, previous = [], F(), F()
|
||||
for start, end in pattern:
|
||||
cursor += (start - previous) * unit
|
||||
duration = max(F(1, 2), (end - start) * unit) if unit < 1 else (end - start) * unit
|
||||
result.append((cursor, cursor + duration))
|
||||
cursor += duration
|
||||
previous = end
|
||||
length = cursor + (period - previous) * unit
|
||||
if connect:
|
||||
needed = max(F(), occupancy * length - sum(b - a for a, b in result))
|
||||
for i, (a, b) in enumerate(result):
|
||||
following = result[i + 1][0] if i + 1 < len(result) else length
|
||||
# Occupancy is a soft objective, not a duration denominator. Use a half
|
||||
# beat or the existing rest endpoint; never manufacture e.g. 7/40 beats
|
||||
# solely to hit a percentage exactly.
|
||||
extra = min(F(1, 2), following - b)
|
||||
if abs(needed - extra) >= needed:
|
||||
extra = F()
|
||||
result[i] = (a, b + extra)
|
||||
needed -= extra
|
||||
return tuple(result), length
|
||||
|
||||
def ending_options(anchors, plan, choices):
|
||||
if not anchors or plan.target or plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
|
||||
return tuple([anchors])
|
||||
terminal = next((a for a in anchors if a.role == 'terminal'), None)
|
||||
if terminal is None or terminal.hold_until is None:
|
||||
return tuple([anchors])
|
||||
finish = terminal.hold_until
|
||||
last = next((c for c in choices if c.span.start < finish <= c.span.end), None)
|
||||
result = []
|
||||
for duration in (F(1, 2), F(1), F(2), F(3)):
|
||||
start = finish - duration
|
||||
if last is None or start < max(last.span.start, plan.passage_start or plan.start) or any(a.role != 'terminal' and a.latest + a.duration > start for a in anchors):
|
||||
continue
|
||||
changed = dataclasses.replace(terminal, earliest=start, latest=start, duration=duration)
|
||||
result.append(tuple(changed if a.id == terminal.id else a for a in anchors))
|
||||
return tuple(result) or tuple([anchors])
|
||||
|
||||
def align_anchors(slots, anchors, plan):
|
||||
"""Choose arrival timing with the rhythm, before assigning any free pitches."""
|
||||
slots = list(slots)
|
||||
exact = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
|
||||
protected_end = plan.start + plan.motif.duration if exact else plan.start
|
||||
for anchor in anchors:
|
||||
if not plan.start <= anchor.earliest < plan.end - plan.release:
|
||||
continue
|
||||
possible = [s for s in slots if anchor.earliest <= s.start <= anchor.latest]
|
||||
fit = [s for s in possible if s.end - s.start >= anchor.duration]
|
||||
selected = (fit or possible)
|
||||
slot = selected[0] if selected else None
|
||||
time = slot.start if slot else anchor.earliest
|
||||
end = anchor.hold_until or time + anchor.duration
|
||||
end = min(end, plan.end - plan.release)
|
||||
if time < protected_end:
|
||||
if slot is None or slot.end < end or (slot.forced and slot.forced != anchor.pitch):
|
||||
return None
|
||||
slots[slots.index(slot)] = dataclasses.replace(slot, forced=anchor.pitch)
|
||||
continue
|
||||
group = slot.group if slot else next((s.group for s in reversed(slots) if s.start <= time), 0)
|
||||
event_id = slot.id if slot else anchor.id
|
||||
replacement = Slot(event_id, time, end, group, slot.reference if slot else 0, anchor.pitch)
|
||||
before = [dataclasses.replace(s, end=min(s.end, time)) for s in slots if s.start < time]
|
||||
slots = [s for s in before if s.end > s.start] + [replacement] + [s for s in slots if s.start >= end and s.start != time]
|
||||
return tuple(slots)
|
||||
|
||||
def construct(plan, reference, relationship, template, anchors, variant, choices, octave=0):
|
||||
identifier, pattern, period = template
|
||||
if not pattern or period <= 0:
|
||||
return None
|
||||
finish = plan.end - plan.release
|
||||
slots, groups, cursor = (), [], plan.start
|
||||
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
|
||||
schedule = (('repeat', 'extend', 'answer'), ('extend', 'displace', 'recall'))[variant % 2]
|
||||
while cursor < finish:
|
||||
index = len(groups)
|
||||
operation = relationship if index == 0 else schedule[(index - 1) % len(schedule)]
|
||||
activity = next((p for p in plan.passages if p.start <= cursor < p.end), None)
|
||||
rate = F(len(pattern), 1) / period
|
||||
unit = F(2) if activity and activity.activity < F(3, 4) else F(1, 2) if activity and activity.activity > rate + F(1, 4) and index else F(1)
|
||||
local = pattern
|
||||
length = period
|
||||
if index and operation == 'extend':
|
||||
slots, cursor = reopen(slots, cursor)
|
||||
# Continue a recognizable opening beyond the old cadence; this group
|
||||
# can span either side of a barline without a new catalog selection.
|
||||
tail = pattern[:2]
|
||||
local = (*pattern, *((period + a, period + b) for a, b in tail))
|
||||
length += tail[-1][1]
|
||||
elif index and operation == 'answer':
|
||||
local = pattern[:max(2, len(pattern) - 1)]
|
||||
length = local[-1][1]
|
||||
elif index and operation == 'recall':
|
||||
operation = 'repeat_with_harmonic_adaptation'
|
||||
elif index and operation == 'displace':
|
||||
local = tuple((a + F(1, 2), b + F(1, 2)) for a, b in pattern)
|
||||
length += F(1, 2)
|
||||
if index and unit == 2:
|
||||
operation = 'fragment'
|
||||
local = local[:3]
|
||||
length = local[-1][1]
|
||||
if index and not protected:
|
||||
connected = operation not in ('repeat', 'repeat_with_harmonic_adaptation')
|
||||
local, length = develop_timing(local, length, unit, activity.occupancy if activity else F(3, 4), connected)
|
||||
if unit < 1:
|
||||
operation = 'diminish' if operation != 'extend' else 'extend_diminished'
|
||||
elif unit > 1:
|
||||
operation = 'broaden_ending'
|
||||
unit = F(1)
|
||||
if activity and activity.intent == 'space':
|
||||
cursor = min(activity.end, finish)
|
||||
continue
|
||||
if cursor + unit * length > finish:
|
||||
operation = 'close' if index else operation
|
||||
group_end = min(finish, cursor + unit * length)
|
||||
if group_end <= cursor:
|
||||
break
|
||||
group = Group(f'{plan.id}_development_{index + 1}', operation, cursor, group_end, identifier)
|
||||
groups.append(group)
|
||||
new = []
|
||||
for j, (a, b) in enumerate(local):
|
||||
start, end = cursor + a * unit, min(group_end, cursor + b * unit)
|
||||
if start >= end:
|
||||
continue
|
||||
quote = protected and index == 0
|
||||
pitch = None
|
||||
if quote and plan.treatment in ('quote', 'exact_quote'):
|
||||
pitch = dataclasses.replace(reference.notes[j].pitch, octave=reference.notes[j].pitch.octave + octave)
|
||||
new.append(Slot(f'{group.id}_event_{j + 1}', start, end, index, j % len(pattern), pitch, quote))
|
||||
slots = (*slots, *new)
|
||||
cursor = group_end
|
||||
if protected and plan.start + reference.duration > finish:
|
||||
return None
|
||||
aligned = align_anchors(slots, anchors, plan)
|
||||
if aligned is None:
|
||||
return None
|
||||
# Missing harmony is explicit space. Only ordinary material may fragment.
|
||||
filtered = []
|
||||
for slot in aligned:
|
||||
pieces = [(slot.start, slot.end)]
|
||||
for choice in choices:
|
||||
if choice.scale is None:
|
||||
pieces = [(a, b) for low, high in pieces for a, b in ((low, min(high, choice.span.start)), (max(low, choice.span.end), high)) if b > a]
|
||||
if slot.protected and pieces != [(slot.start, slot.end)]:
|
||||
return None
|
||||
filtered.extend(dataclasses.replace(slot, id=slot.id + (f'_fragment_{i}' if len(pieces) > 1 else ''), start=a, end=b) for i, (a, b) in enumerate(pieces))
|
||||
# The finalized rhythm is the contract, including changes made by extension
|
||||
# and anchor alignment. It is never rebased when a cell or bar ends.
|
||||
identity = generator.solo.policy.stable_rank(plan.seed, (identifier, variant, anchors, relationship, octave))[:12]
|
||||
return RhythmDraft(identity, tuple(filtered), tuple(groups), anchors, reference, relationship, octave)
|
||||
|
||||
def rhythms(plan, incoming, measures, choices, library, batch=0):
|
||||
continuing = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
|
||||
anchors = (incoming.selected_anchors,) if continuing and incoming.selected_anchors else plan.anchor_options or tuple([()])
|
||||
references = [(plan.motif, 'statement')]
|
||||
if incoming.home and plan.treatment not in ('quote', 'exact_quote', 'rhythmic_reference'):
|
||||
references = [(incoming.home, 'continue' if incoming.ending == 'connected' or continuing else 'answer')]
|
||||
if not continuing:
|
||||
references.append((incoming.home, 'recall'))
|
||||
if plan.motif.id != incoming.home.parent:
|
||||
references.append((plan.motif, 'contrast'))
|
||||
if incoming.themes and incoming.themes[0].id != incoming.home.id:
|
||||
references.append((incoming.themes[0], 'recall'))
|
||||
elapsed = F()
|
||||
meter = measures[0]['time_signature']
|
||||
for measure in measures:
|
||||
end = elapsed + generator.model.fraction(measure['duration'])
|
||||
if elapsed <= plan.start < end:
|
||||
meter = measure['time_signature']
|
||||
break
|
||||
elapsed = end
|
||||
result = []
|
||||
for reference, relationship in references:
|
||||
available_templates = templates(plan, reference, library, meter)
|
||||
if isinstance(reference, generator.solo.types.RealizedMotif):
|
||||
available_templates = available_templates[:1]
|
||||
for template in available_templates:
|
||||
for backbone in anchors:
|
||||
endings = (backbone,) if continuing else ending_options(backbone, plan, choices)
|
||||
for ending in endings:
|
||||
for variant in range(2):
|
||||
for octave in ((0, 1, -1, 2, -2) if plan.treatment in ('quote', 'exact_quote') else tuple([0])):
|
||||
rhythm = construct(plan, reference, relationship, template, ending, variant, choices, octave)
|
||||
if rhythm:
|
||||
result.append(rhythm)
|
||||
evaluation = generator.solo.scoring.Evaluation(dataclasses.replace(plan, passages=tuple(p for p in plan.passages if p.end > plan.start)), choices)
|
||||
|
||||
def rank(rhythm):
|
||||
dummy = tuple(generator.solo.types.Note(s.start, s.end, s.forced or rhythm.reference.notes[0].pitch) for s in rhythm.slots)
|
||||
features = evaluation.features(dummy)
|
||||
return features['activity'] * 4 + features['occupancy'] * 4, generator.solo.policy.stable_rank(plan.seed, rhythm.id)
|
||||
|
||||
def family(rhythm):
|
||||
pitches = tuple(a.pitch.number() for a in rhythm.anchors)
|
||||
shape = generator.solo.sequences.family(tuple(b - a for a, b in zip(pitches, pitches[1:])))
|
||||
return rhythm.relationship, shape, rhythm.groups[0].source, rhythm.anchors[-1].duration if rhythm.anchors else None
|
||||
|
||||
limit = generator.solo.policy.DEFAULT.draft_frontier
|
||||
return generator.solo.sequences.diverse(result, rank, family, limit * (batch + 1))[limit * batch:]
|
||||
|
||||
def canonical_context(notes, incoming, plan):
|
||||
prefix = incoming.notes if incoming.passage_id == plan.passage_id and plan.passage_id else ((incoming.last,) if incoming.last else ())
|
||||
return generator.solo.types.sounding_notes((*prefix, *notes))
|
||||
|
||||
def local_sounding(notes):
|
||||
if notes and notes[0].continuation:
|
||||
notes = (dataclasses.replace(notes[0], continuation=False), *notes[1:])
|
||||
return generator.solo.types.sounding_notes(notes)
|
||||
|
||||
def contract_valid(draft, notes):
|
||||
by_id = {n.event_id: n for n in local_sounding(notes)}
|
||||
original = {n.event_id: n for n in draft.notes}
|
||||
for identity in (*draft.structural, *draft.protected):
|
||||
before, after = original[identity], by_id.get(identity)
|
||||
if after is None or (before.start, before.end, before.pitch) != (after.start, after.end, after.pitch):
|
||||
return False, f'{identity}: protected draft event changed'
|
||||
for transform in draft.transformations:
|
||||
if not transform.preserves:
|
||||
continue
|
||||
core = [by_id.get(identity) for identity in transform.events[:min(3, len(transform.events))]]
|
||||
if not all(core):
|
||||
return False, f'{transform.id}: identifying events missing'
|
||||
if 'rhythm' in transform.preserves and tuple((n.start - transform.start, n.end - transform.start) for n in core) != transform.rhythm[:len(core)]:
|
||||
return False, f'{transform.id}: identifying rhythm changed'
|
||||
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(core, core[1:]))
|
||||
if 'opening_contour' in transform.preserves and directions != transform.directions[:len(directions)]:
|
||||
return False, f'{transform.id}: identifying contour changed'
|
||||
return True, ''
|
||||
|
||||
def relationship_features(draft, notes):
|
||||
"""Inspect relationships once, on canonical events, independent of cells."""
|
||||
notes = local_sounding(notes)
|
||||
by_id = {n.event_id: n for n in notes}
|
||||
preservation = sum(identity not in by_id for identity in draft.structural) / max(1, len(draft.structural))
|
||||
direction, development = [], []
|
||||
for transform in draft.transformations:
|
||||
group = [by_id[i] for i in transform.events if i in by_id]
|
||||
actual = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(group, group[1:]))
|
||||
direction.extend(a != b for a, b in zip(actual, transform.directions))
|
||||
if draft.home and transform.operation not in ('statement', 'contrast', 'close', 'fragment', 'broaden_ending'):
|
||||
development.append(generator.solo.scoring.motif_fidelity(group[:3], draft.home, 'opening_reference'))
|
||||
joins = []
|
||||
for time in draft.joins:
|
||||
left = next((n for n in reversed(notes) if n.start < time), None)
|
||||
right = next((n for n in notes if n.start >= time), None)
|
||||
if left and right:
|
||||
original_left = next((n for n in reversed(draft.notes) if n.start < time), None)
|
||||
original_right = next((n for n in draft.notes if n.start >= time), None)
|
||||
planned_gap = max(F(), original_right.start - original_left.end) if original_left and original_right else F()
|
||||
gap = max(F(), right.start - left.end - planned_gap)
|
||||
joins.append(min(1, float(gap) / 2) + min(1, max(0, abs(right.pitch.number() - left.pitch.number()) - 4) / 12))
|
||||
terminal = next((a for a in draft.anchors if a.role == 'terminal' and a.earliest < draft.end), None)
|
||||
integration = 0.0
|
||||
if terminal:
|
||||
arrival = generator.solo.harmonic.anchor_note(terminal, notes)
|
||||
before = next((n for n in reversed(notes) if arrival and n.start < arrival.start), None)
|
||||
integration = min(1, max(0, abs(arrival.pitch.number() - before.pitch.number()) - 4) / 12) if arrival and before else 0
|
||||
return {'draft_preservation': preservation, 'development': sum(development) / max(1, len(development)), 'direction': sum(direction) / max(1, len(direction)), 'join_coherence': sum(joins) / max(1, len(joins)), 'ending_integration': integration}
|
||||
@@ -130,7 +130,7 @@ def anchor_backbones(identifier, start, end, release, register, choices, bar, se
|
||||
if terminal_start <= opening + fractions.Fraction(1, 2):
|
||||
moments = []
|
||||
moments.append((terminal_start, 'terminal'))
|
||||
states = [(0.0, ())]
|
||||
states = [(0.0, (), intention) for intention in ('rising', 'falling', 'returning', 'repeated')]
|
||||
for index, (time, role) in enumerate(moments):
|
||||
choice = next(c for c in active if c.span.start <= time < c.span.end)
|
||||
chord_roles = generator.solo.harmonic.roles(choice.span.chord)
|
||||
@@ -141,17 +141,18 @@ def anchor_backbones(identifier, start, end, release, register, choices, bar, se
|
||||
pitches = [p for p in pitches if register[0] <= p.number() <= register[1] and p.number() % 12 in eligible and generator.fretboard.single_positions_for_pitch(fractions.Fraction(p.number()))]
|
||||
desired = register[0] + (5 if index == 0 else 8 if role == 'terminal' else 11)
|
||||
following = []
|
||||
for cost, anchors in states:
|
||||
for cost, anchors, intention in states:
|
||||
for pitch in pitches:
|
||||
jump = abs(pitch.number() - anchors[-1].pitch.number()) if anchors else 0
|
||||
direction = 1 if role != 'terminal' else -1
|
||||
direction = 1 if intention == 'rising' or intention == 'returning' and role != 'terminal' else -1 if intention != 'repeated' else 0
|
||||
wrong_way = bool(anchors and (pitch.number() - anchors[-1].pitch.number()) * direction < 0)
|
||||
preferred = target_pitches(choice.span.chord, role == 'terminal')
|
||||
score = cost + jump / 12 + 0.3 * wrong_way + abs(pitch.number() - desired) / 18 + 0.3 * (pitch.number() % 12 not in chord_roles.basic) + 0.2 * (role == 'terminal' and pitch.number() % 12 not in preferred)
|
||||
destination_motion = 0 if intention == 'repeated' else 4
|
||||
score = cost + abs(jump - destination_motion) / 12 + 0.8 * wrong_way + abs(pitch.number() - desired) / 36 + 0.3 * (pitch.number() % 12 not in chord_roles.basic) + 0.2 * (role == 'terminal' and pitch.number() % 12 not in preferred)
|
||||
anchor = generator.solo.types.Anchor(f'{identifier}_anchor_{index + 1}', time, latest, pitch, frozenset(eligible), register, duration, role, generator.harmony.symbol(choice.span.chord), False, finish if role == 'terminal' else None)
|
||||
following.append((score, (*anchors, anchor)))
|
||||
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], generator.solo.policy.stable_rank(seed, tuple(a.pitch for a in item[1]))), lambda item: (item[1][0].pitch.number(), item[1][-1].pitch.number()), generator.solo.policy.DEFAULT.alternatives))
|
||||
return tuple(anchors for _, anchors in states)
|
||||
following.append((score, (*anchors, anchor), intention))
|
||||
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], generator.solo.policy.stable_rank(seed, tuple(a.pitch for a in item[1]))), lambda item: (item[2], item[1][0].pitch.number()), generator.solo.policy.DEFAULT.anchor_lines))
|
||||
return tuple(dict.fromkeys(anchors for _, anchors, _ in states))
|
||||
|
||||
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()]
|
||||
@@ -207,23 +208,13 @@ def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choi
|
||||
if slot < 2 and slot not in inventions:
|
||||
inventions[slot] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed, variant=slot)
|
||||
motif = head if slot == 2 else inventions[slot]
|
||||
gestures, cursor = [], start
|
||||
while cursor < finish - release:
|
||||
gesture_end = min(cursor + bar, finish - release)
|
||||
closing = gesture_end == finish - release
|
||||
changed = any(c.span.start == cursor and c.scale and prior.scale and c.scale.pitches != prior.scale.pitches for prior, c in zip(choices, choices[1:]))
|
||||
relationship = 'statement' if not gestures else 'close' if closing else 'repeat_with_harmonic_adaptation' if changed else 'answer' if cursor >= start + (finish - start) / 2 else 'extend'
|
||||
treatment = 'opening_reference' if not gestures and motif.origin == 'melody' else 'invented' if not gestures else 'rhythmic_reference' if relationship == 'repeat_with_harmonic_adaptation' else 'answer' if relationship in ('answer', 'close') else 'contour_reference'
|
||||
gestures.append(generator.solo.types.Gesture(f'{passage_id}_gesture_{len(gestures) + 1}', cursor, gesture_end, relationship, treatment))
|
||||
cursor = gesture_end
|
||||
phrase_start = start
|
||||
for phrase_stop in phrase_stops:
|
||||
phrase_end = boundaries[phrase_stop]
|
||||
terminal = phrase_end == finish
|
||||
treatment = 'opening_reference' if motif.origin == 'melody' else 'invented'
|
||||
local_gestures = tuple(g for g in gestures if g.start < phrase_end and g.end > phrase_start)
|
||||
landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, min(v[i].pitch.number() for v in anchors), max(v[i].pitch.number() for v in anchors), 0, a.role) for i, a in enumerate(anchors[0])) if anchors and anchors[0] else ()
|
||||
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', phrase_start, phrase_end, motif, 'statement' if phrase_start == start else 'continue', treatment, None, register, activity, release if terminal else fractions.Fraction(), envelope, landmarks, 'breath' if terminal and release else 'sustain' if terminal else 'connected', 'closed' if terminal else 'open', passage_seed, passage_id, start, finish, anchors, local_gestures))
|
||||
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', phrase_start, phrase_end, motif, 'statement' if phrase_start == start else 'continue', treatment, None, register, activity, release if terminal else fractions.Fraction(), envelope, landmarks, 'breath' if terminal and release else 'sustain' if terminal else 'connected', 'closed' if terminal else 'open', passage_seed, passage_id, start, finish, anchors))
|
||||
phrase_start = phrase_end
|
||||
index = stop
|
||||
return tuple(result)
|
||||
|
||||
@@ -11,7 +11,7 @@ class Policy:
|
||||
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), ('harmonic_exposure', 2))
|
||||
weights: tuple[tuple[str, float], ...] = (('activity', 4), ('occupancy', 4), ('fidelity', 3), ('landmarks', 3), ('register', 1), ('contour', 1), ('connection', 1), ('repetition', 1), ('movement', 0.5), ('harmonic_exposure', 2), ('draft_preservation', 3), ('development', 2), ('direction', 1), ('join_coherence', 2), ('ending_integration', 2))
|
||||
beam_width: int = 64
|
||||
extensions: int = 32
|
||||
retrieval: int = 32
|
||||
@@ -21,6 +21,12 @@ class Policy:
|
||||
suspension_preparation: float = 0.5
|
||||
suspension_resolution: float = 1.0
|
||||
replan_attempts: int = 2
|
||||
anchor_lines: int = 8
|
||||
draft_frontier: int = 16
|
||||
phrase_alternatives: int = 8
|
||||
join_alternatives: int = 8
|
||||
revision_sweeps: int = 2
|
||||
join_window: float = 2.0
|
||||
|
||||
# Preserve the former default root seeds while decoupling them from policy edits.
|
||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||
|
||||
+410
-100
@@ -3,6 +3,9 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
import time
|
||||
|
||||
import generator.solo.drafts
|
||||
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
@@ -348,121 +351,428 @@ def candidates(cell, plan, choices, sequences, state=None, anchors=(), harmony=N
|
||||
class SearchContext:
|
||||
pools: dict = dataclasses.field(default_factory=dict)
|
||||
results: dict = dataclasses.field(default_factory=dict)
|
||||
resources: dict = dataclasses.field(default_factory=dict)
|
||||
revise: bool = True
|
||||
batch: int = 0
|
||||
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'
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class BeamState:
|
||||
score: float
|
||||
decisions: tuple
|
||||
music: generator.solo.types.ContinuationState
|
||||
travel: int = 0
|
||||
fidelity: tuple[float, ...] = ()
|
||||
def phrase_context(plan, incoming, notes):
|
||||
return generator.solo.drafts.canonical_context(notes, incoming, plan)
|
||||
|
||||
def evaluation_plan(plan, anchors):
|
||||
landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, a.pitch.number(), a.pitch.number(), name=a.role) for a in anchors)
|
||||
return dataclasses.replace(plan, start=plan.passage_start if plan.passage_start is not None else plan.start, end=plan.passage_end or plan.end, landmarks=landmarks)
|
||||
|
||||
def inherited_treatments(plan, incoming):
|
||||
if plan.passage_id and plan.passage_id == incoming.passage_id:
|
||||
return incoming.treatments
|
||||
retained = []
|
||||
for treatment in incoming.treatments:
|
||||
future = treatment.start >= plan.start
|
||||
color = treatment.kind == 'color' and incoming.last and treatment.end > incoming.last.start
|
||||
pending = treatment.kind == 'suspension' and incoming.last and incoming.last.pitch.number() == treatment.pitch and incoming.last.end > treatment.start and not any(n.start >= treatment.start and n.pitch.number() == treatment.resolution for n in incoming.notes)
|
||||
if future or color or pending:
|
||||
retained.append(treatment)
|
||||
# Completed contracts remain in their original phrase reports; only the
|
||||
# sounding tail and unresolved obligations require context in a new passage.
|
||||
return tuple(retained)
|
||||
|
||||
def draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, context):
|
||||
"""Instantiate a complete timed line. Boundaries here are musical groups."""
|
||||
F = fractions.Fraction
|
||||
contracts = tuple(dict.fromkeys((*inherited_treatments(plan, incoming), *plan.treatments, *anchor_treatments(rhythm.anchors, harmony))))
|
||||
# Cost, actual pitches so far, and the identity of the introduced statement.
|
||||
states = [(0.0, (), rhythm.reference.notes if isinstance(rhythm.reference, generator.solo.types.RealizedMotif) else ())]
|
||||
next_anchor = [next((a for a in rhythm.anchors if a.latest >= s.start), None) for s in rhythm.slots]
|
||||
for index, slot in enumerate(rhythm.slots):
|
||||
available = options(choices, slot.start, slot.end, plan.register)
|
||||
pending = next((t for t in contracts if t.kind == 'suspension' and t.deadline is not None and incoming.last and incoming.last.pitch.number() == t.pitch and incoming.last.end == slot.start and t.start <= slot.start <= t.deadline and not any(n.start >= t.start and n.pitch.number() == t.resolution for n in incoming.notes)), None)
|
||||
if pending:
|
||||
available = tuple(p for p in available if p.number() == pending.resolution)
|
||||
if slot.forced:
|
||||
available = tuple(p for p in available if p == slot.forced)
|
||||
quoted = slot.protected and plan.treatment in ('quote', 'exact_quote')
|
||||
if not quoted and slot.end - slot.start >= generator.solo.policy.DEFAULT.long_note:
|
||||
members = harmony.members(slot.start, slot.end)
|
||||
available = tuple(p for p in available if p.number() % 12 in members or any(t.pitch == p.number() and t.start <= slot.start and t.end >= slot.end for t in contracts))
|
||||
if not available:
|
||||
return (), f'{slot.id}: no valid pitch for sounding interval {slot.start}-{slot.end}'
|
||||
following = []
|
||||
group = rhythm.groups[slot.group]
|
||||
local_index = sum(s.group == slot.group for s in rhythm.slots[:index])
|
||||
remaining = sum(s.start >= slot.start and (not next_anchor[index] or s.start <= next_anchor[index].latest) for s in rhythm.slots[index:])
|
||||
span = harmony.at(slot.start)
|
||||
for cost, notes, home in states:
|
||||
prior = notes[-1] if notes else incoming.last
|
||||
reference = home if home and slot.group else rhythm.reference.notes
|
||||
if local_index >= 3 and group.operation in ('extend', 'extend_diminished', 'answer', 'continue'):
|
||||
previous_ids = {s.id for s in rhythm.slots if s.group == slot.group - 1}
|
||||
recent = tuple(n for n in notes if n.event_id in previous_ids) if slot.group else incoming.development.notes if incoming.development else ()
|
||||
reference = recent or reference
|
||||
contour = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(reference, reference[1:]))
|
||||
step = contour[(max(0, slot.reference - 1)) % len(contour)] if contour else 0
|
||||
if slot.reference == 0 and local_index == 0:
|
||||
# A group continues the incoming line; it does not teleport to the
|
||||
# source motif's register or to the center of a fresh bar.
|
||||
step = contour[-1] if contour and group.operation == 'extend' else 0
|
||||
expected = prior.pitch.number() + step if prior else rhythm.reference.notes[0].pitch.number()
|
||||
anchor = next_anchor[index]
|
||||
target = anchor.pitch.number() if anchor else expected
|
||||
route = expected
|
||||
if prior and anchor and span and span.scale:
|
||||
try:
|
||||
start_degree = generator.solo.types.scale_index(span.scale, prior.pitch.number())
|
||||
target_degree = generator.solo.types.scale_index(span.scale, target)
|
||||
displacement = target_degree - start_degree
|
||||
count = min(16, max(2, remaining + 1))
|
||||
paths = sequences.retrieve(count, displacement, (displacement > 0) - (displacement < 0))
|
||||
if paths:
|
||||
route = generator.solo.types.scale_pitch(span.scale, start_degree + paths[0].steps[0]).number()
|
||||
except ValueError:
|
||||
# Collection changes preserve the incoming sound, never its old index.
|
||||
route = prior.pitch.number() + ((target > prior.pitch.number()) - (target < prior.pitch.number())) * 2
|
||||
ranked = sorted(available, key=lambda p: (abs(p.number() - expected), p))[:3]
|
||||
if available:
|
||||
bridge = min(available, key=lambda p: (abs(p.number() - route), p))
|
||||
if bridge not in ranked:
|
||||
ranked.append(bridge)
|
||||
for pitch in ranked:
|
||||
direction = (pitch.number() > prior.pitch.number()) - (pitch.number() < prior.pitch.number()) if prior else 0
|
||||
intended = (step > 0) - (step < 0)
|
||||
identifying = bool(local_index in (1, 2) and group.operation not in ('close', 'answer') and not slot.forced)
|
||||
if identifying and reference and direction != intended:
|
||||
continue
|
||||
jump = abs(pitch.number() - prior.pitch.number()) if prior else 0
|
||||
stopping = slot.end - slot.start >= generator.solo.policy.DEFAULT.stopping_note
|
||||
motion_cost = abs(pitch.number() - expected) / 12 * (1.5 if identifying else 0.35)
|
||||
direction_cost = max(0, abs(pitch.number() - target) - 2 * max(0, remaining - 1)) / 12 if anchor else 0
|
||||
exposure = 0.6 if stopping and span and pitch.number() % 12 not in span.roles.basic and not quoted else 0
|
||||
score = cost + motion_cost + direction_cost + max(0, jump - 5) / 12 + exposure
|
||||
sources = reference[min(slot.reference, len(reference) - 1)].sources if reference else ()
|
||||
note = generator.solo.types.Note(slot.start, slot.end, pitch, sources, 'quote' if quoted else 'motif', event_id=slot.id)
|
||||
path = (*notes, note)
|
||||
identity = home
|
||||
if not identity and (index + 1 == len(rhythm.slots) or rhythm.slots[index + 1].group != slot.group):
|
||||
identity = tuple(n for n in path if n.event_id in {s.id for s in rhythm.slots if s.group == slot.group})
|
||||
following.append((score, path, identity))
|
||||
if not following:
|
||||
return (), f'{group.id}: identifying motion cannot reach the selected anchor within the draft budget'
|
||||
boundary = index + 1 == len(rhythm.slots) or rhythm.slots[index + 1].group != slot.group
|
||||
limit = generator.solo.policy.DEFAULT.draft_frontier if boundary else BEAM_WIDTH
|
||||
# Relevant home/contour alternatives survive; no barline participates.
|
||||
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], tuple(n.pitch for n in item[1])), lambda item: (tuple(n.pitch for n in item[2][:3]), item[1][-1].pitch), limit))
|
||||
context.diagnostics['evaluated_extensions'] += len(following)
|
||||
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
|
||||
return tuple((notes, cost / max(1, len(notes)), contracts) for cost, notes, _ in states[:4]), ''
|
||||
|
||||
def make_identity(identifier, parent, notes, start, end, relationship, harmony):
|
||||
local = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start, position=None) for n in notes)
|
||||
degrees = tuple(dict(harmony.at(n.start).roles.degrees).get(n.pitch.number() % 12, ()) for n in notes)
|
||||
return generator.solo.types.RealizedMotif(identifier, parent, local, end - start, relationship, 'developed_rhythm', degrees, start)
|
||||
|
||||
def make_draft(rhythm, notes, plan, harmony, incoming):
|
||||
transforms, structural, protected = [], [], []
|
||||
home = incoming.home if rhythm.relationship not in ('statement', 'contrast') else None
|
||||
if rhythm.relationship == 'recall' and isinstance(rhythm.reference, generator.solo.types.RealizedMotif):
|
||||
home = rhythm.reference
|
||||
parent = incoming.development.id if incoming.development and home else rhythm.reference.id
|
||||
for index, group in enumerate(rhythm.groups):
|
||||
ids = {s.id for s in rhythm.slots if s.group == index}
|
||||
actual = tuple(n for n in notes if n.event_id in ids)
|
||||
if not actual:
|
||||
continue
|
||||
next_start = rhythm.groups[index + 1].start if index + 1 < len(rhythm.groups) else group.end
|
||||
start, end = group.start, max(min(group.end, next_start), actual[-1].end)
|
||||
identity = make_identity(group.id, parent, actual, start, end, group.operation, harmony)
|
||||
if home is None:
|
||||
home = identity
|
||||
structural.extend(n.event_id for n in actual[:3])
|
||||
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(actual, actual[1:]))
|
||||
# Closing simplification explicitly releases the characteristic core.
|
||||
preserves = () if group.operation == 'close' else ('rhythm', 'opening_contour')
|
||||
if plan.treatment == 'rhythmic_reference':
|
||||
preserves = ('rhythm',)
|
||||
transforms.append(generator.solo.types.Transformation(group.id, parent, home.id, group.operation, start, end, tuple(n.event_id for n in actual), tuple((n.start - start, n.end - start) for n in actual), directions, preserves, group.source))
|
||||
parent = identity.id
|
||||
protected.extend(s.id for s in rhythm.slots if s.protected)
|
||||
for anchor in rhythm.anchors:
|
||||
note = generator.solo.harmonic.anchor_note(anchor, notes)
|
||||
if note:
|
||||
structural.append(note.event_id)
|
||||
joins = tuple(t.start for t in transforms[1:])
|
||||
return generator.solo.types.MelodicDraft(rhythm.id, plan.start, plan.end, notes, tuple(transforms), tuple(dict.fromkeys(structural)), tuple(protected), rhythm.anchors, home, rhythm.relationship, joins)
|
||||
|
||||
def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edits=()):
|
||||
valid, _ = generator.solo.drafts.contract_valid(draft, notes)
|
||||
if not valid:
|
||||
return None
|
||||
placed = generator.solo.placement.place(notes, tempo, incoming.last)
|
||||
if placed is None:
|
||||
return None
|
||||
travel, notes = placed
|
||||
try:
|
||||
canonical = phrase_context(plan, incoming, notes)
|
||||
except ValueError:
|
||||
return None
|
||||
complete = plan.end == (plan.passage_end or plan.end)
|
||||
checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete)
|
||||
valid, _ = generator.solo.harmonic.anchors_valid(draft.anchors, canonical, plan.end, complete=complete, closure=plan.closure)
|
||||
if not checked.valid or not valid or not target_ok(notes, plan.target):
|
||||
return None
|
||||
shared = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
|
||||
score_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
|
||||
evaluation = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end)
|
||||
if not evaluation.feasible(score_notes):
|
||||
return None
|
||||
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0))
|
||||
features.update(generator.solo.drafts.relationship_features(draft, notes))
|
||||
# Fidelity is checked against explicit transformed identities, not against
|
||||
# the source head as one undifferentiated phrase-long quotation.
|
||||
features['fidelity'] = features['development']
|
||||
features['harmonic_exposure'] = checked.exposure
|
||||
intended_repeats = 0
|
||||
if draft.home:
|
||||
reference = draft.home.notes
|
||||
for transform in draft.transformations:
|
||||
actual = tuple(n for n in notes if n.event_id in transform.events)
|
||||
for i, (a, b, c) in enumerate(zip(actual, actual[1:], actual[2:])):
|
||||
expected = tuple(reference[j % len(reference)].pitch for j in (i, i + 1, i + 2)) if reference else ()
|
||||
if a.pitch == b.pitch == c.pitch and len(expected) == 3 and expected[0] == expected[1] == expected[2]:
|
||||
intended_repeats += 1
|
||||
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(score_notes) - 2))
|
||||
score = generator.solo.scoring.value(features)
|
||||
motifs = list(incoming.motifs) if shared else []
|
||||
decisions = []
|
||||
for transform in draft.transformations:
|
||||
actual = tuple(n for n in notes if n.event_id in transform.events or transform.start <= n.start < transform.end and n.event_id not in {e for t in draft.transformations for e in t.events})
|
||||
if not actual:
|
||||
continue
|
||||
identity = make_identity(transform.id, transform.parent, actual, transform.start, transform.end, transform.operation, harmony)
|
||||
motifs.append(identity)
|
||||
treatment = f'quote octave {round((actual[0].pitch.number() - plan.motif.notes[0].pitch.number()) / 12):+}' if actual[0].role == 'quote' else transform.operation
|
||||
decisions.append(generator.solo.types.Decision(min(n.start for n in actual), max(n.end for n in actual), 'phrase draft ' + draft.id + '; ' + transform.operation, treatment, actual, 0, transform.id, transform.parent))
|
||||
# Output ordering follows absolute events, even when a transformation's tail
|
||||
# was reopened by an overlapping extension.
|
||||
decisions.sort(key=lambda d: d.start)
|
||||
emitted = tuple(n for d in decisions for n in d.notes)
|
||||
if emitted != notes:
|
||||
return None
|
||||
home = next((m for m in motifs if draft.home and m.id == draft.home.id), draft.home)
|
||||
themes = tuple(dict.fromkeys((*incoming.themes, *((home,) if home else ()))))
|
||||
state_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
|
||||
last = state_notes[-1] if state_notes else incoming.last
|
||||
state = generator.solo.types.ContinuationState(plan.passage_id, state_notes, tuple(motifs), contracts,
|
||||
tuple(a.id for a in draft.anchors if generator.solo.harmonic.anchor_note(a, canonical)),
|
||||
(notes[-1].pitch.number() > notes[0].pitch.number()) - (notes[-1].pitch.number() < notes[0].pitch.number()) if notes else incoming.direction,
|
||||
last, 0, (), travel + (incoming.travel if shared else 0), home, motifs[-1] if motifs else incoming.development, themes, draft.anchors, plan.ending)
|
||||
actual_plan = dataclasses.replace(plan, relationship=draft.relationship)
|
||||
return generator.solo.types.Phrase(actual_plan, tuple(decisions), score, state, draft.anchors, draft, edits)
|
||||
|
||||
def legacy_target_plan(plan, choices):
|
||||
if not plan.target or plan.anchor_options:
|
||||
return plan
|
||||
target = plan.target
|
||||
duration = fractions.Fraction(1, 2)
|
||||
arrival = next((p for p in plan.passages if p.start <= target.earliest < p.end and p.intent == 'sustained_arrival'), None)
|
||||
if arrival and arrival.activity <= fractions.Fraction(1, 4):
|
||||
duration = arrival.end - target.earliest
|
||||
pitches = options(choices, target.earliest, target.earliest + duration, plan.register)
|
||||
anchors = tuple((generator.solo.types.Anchor(plan.id + '_target', target.earliest, target.latest, pitch, target.pitches, plan.register, duration, 'terminal', target.chord, True, target.earliest + duration),) for pitch in pitches if pitch.number() % 12 in target.pitches)
|
||||
return dataclasses.replace(plan, anchor_options=anchors)
|
||||
|
||||
def emitted_notes(phrase):
|
||||
return tuple(n for d in phrase.decisions for n in d.notes)
|
||||
|
||||
def window_variants(phrase, low, high, harmony, sequences, kind):
|
||||
"""Two fixed outside endpoints bound edits to the existing melody."""
|
||||
notes = emitted_notes(phrase)
|
||||
protected = set((*phrase.draft.structural, *phrase.draft.protected))
|
||||
inside = [i for i, n in enumerate(notes) if low <= n.start and n.end <= high]
|
||||
editable = [i for i in inside if notes[i].event_id not in protected]
|
||||
if not editable:
|
||||
return ()
|
||||
candidates = []
|
||||
left, right = max(0, inside[0] - 1), min(len(notes) - 1, inside[-1] + 1)
|
||||
# Route both sides of a join as one path, with the original outside pitches.
|
||||
if 1 < right - left < 16:
|
||||
scale = harmony.at(notes[left].start).scale
|
||||
try:
|
||||
first = generator.solo.types.scale_index(scale, notes[left].pitch.number())
|
||||
displacement = generator.solo.types.scale_index(scale, notes[right].pitch.number()) - first
|
||||
paths = sequences.retrieve(right - left + 1, displacement, (displacement > 0) - (displacement < 0))
|
||||
except ValueError:
|
||||
paths = ()
|
||||
for path in paths[:generator.solo.policy.DEFAULT.join_alternatives]:
|
||||
updated = list(notes)
|
||||
for i in editable:
|
||||
offset = path.offsets()[i - left]
|
||||
intended = generator.solo.types.scale_pitch(scale, first + offset).number()
|
||||
available = options(harmony.choices, notes[i].start, notes[i].end, phrase.plan.register)
|
||||
if available:
|
||||
pitch = min(available, key=lambda p: (abs(p.number() - intended), p))
|
||||
updated[i] = dataclasses.replace(notes[i], pitch=pitch, position=None)
|
||||
candidates.append((tuple(updated), 'sequence ' + ' + '.join(path.sources)))
|
||||
for index in editable:
|
||||
note = notes[index]
|
||||
if index + 1 < len(notes) and notes[index + 1].start <= high:
|
||||
after = notes[index + 1]
|
||||
if after.event_id not in protected and note.end == after.start and note.pitch == after.pitch:
|
||||
candidates.append(((*notes[:index], dataclasses.replace(note, end=after.end), *notes[index + 2:]), 'shared common-tone arrival'))
|
||||
if kind == 'join' and after.event_id not in protected and note.end == after.start and note.end - note.start > fractions.Fraction(1, 2):
|
||||
# The tail and pickup move together, preserving total sounding time.
|
||||
time = after.start - fractions.Fraction(1, 2)
|
||||
candidates.append(((*notes[:index], dataclasses.replace(note, end=time), dataclasses.replace(after, start=time), *notes[index + 2:]), 'reopen tail into pickup'))
|
||||
if kind == 'elaboration' and index + 1 < len(notes) and fractions.Fraction(1) <= note.end - note.start < 2:
|
||||
# Add motion only as an alternative evaluated against the unchanged
|
||||
# activity envelope; keeping the original draft is always available.
|
||||
split = note.end - fractions.Fraction(1, 2)
|
||||
scale = harmony.at(split).scale
|
||||
target = notes[index + 1].pitch
|
||||
try:
|
||||
degree = generator.solo.types.scale_index(scale, note.pitch.number())
|
||||
finish = generator.solo.types.scale_index(scale, target.number())
|
||||
pitch = generator.solo.types.scale_pitch(scale, degree + ((finish > degree) - (finish < degree)))
|
||||
addition = generator.solo.types.Note(split, note.end, pitch, note.sources, 'elaboration', event_id=note.event_id + '_passing')
|
||||
candidates.append(((*notes[:index], dataclasses.replace(note, end=split), addition, *notes[index + 1:]), 'interpolate toward next structural event'))
|
||||
except ValueError:
|
||||
pass
|
||||
return tuple(candidates)
|
||||
|
||||
def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind, context):
|
||||
original = emitted_notes(phrase)
|
||||
viable = [phrase]
|
||||
proposals = window_variants(phrase, low, high, harmony, sequences, kind)
|
||||
# Keep distinct rhythmic and pitch revisions; no generic unlimited search.
|
||||
proposals = generator.solo.sequences.diverse(proposals, lambda p: (abs(len(p[0]) - len(original)), p[1], tuple(n.pitch for n in p[0])), lambda p: (p[1].split()[0], len(p[0])), generator.solo.policy.DEFAULT.join_alternatives)
|
||||
for notes, source in proposals:
|
||||
if notes == original:
|
||||
continue
|
||||
changed_before = tuple(n.event_id for n in original if n not in notes)
|
||||
changed_after = tuple(n.event_id for n in notes if n not in original)
|
||||
edit = generator.solo.types.DraftEdit(kind, low, high, changed_before, changed_after, source)
|
||||
revised = checked_phrase(phrase.plan, phrase.draft, notes, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
|
||||
context.diagnostics[kind + '_attempts'] = context.diagnostics.get(kind + '_attempts', 0) + 1
|
||||
if revised:
|
||||
viable.append(revised)
|
||||
chosen = min(viable, key=lambda p: (p.score, len(p.edits), tuple((n.pitch, n.start, n.end) for n in emitted_notes(p))))
|
||||
if chosen is not phrase:
|
||||
context.diagnostics[kind + '_accepted'] = context.diagnostics.get(kind + '_accepted', 0) + 1
|
||||
return chosen
|
||||
|
||||
def elaborate(phrase, incoming, harmony, sequences, tempo, context):
|
||||
started = time.monotonic()
|
||||
for transform in phrase.draft.transformations:
|
||||
phrase = improve_window(phrase, incoming, harmony, sequences, tempo, transform.start, transform.end, 'elaboration', context)
|
||||
context.diagnostics['elaboration_seconds'] = context.diagnostics.get('elaboration_seconds', 0) + time.monotonic() - started
|
||||
return phrase
|
||||
|
||||
def revise_joins(phrase, incoming, harmony, sequences, tempo, context):
|
||||
started = time.monotonic()
|
||||
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
|
||||
for _ in range(generator.solo.policy.DEFAULT.revision_sweeps):
|
||||
before = phrase
|
||||
for join in phrase.draft.joins:
|
||||
phrase = improve_window(phrase, incoming, harmony, sequences, tempo, max(phrase.plan.start, join - width), min(phrase.plan.end, join + width), 'join', context)
|
||||
if phrase == before:
|
||||
break
|
||||
context.diagnostics['revision_seconds'] = context.diagnostics.get('revision_seconds', 0) + time.monotonic() - started
|
||||
return phrase
|
||||
|
||||
def revise_pair(first, second, incoming, choices, sequences, tempo, context):
|
||||
"""Revise an uncommitted connected phrase seam, then rebuild both states."""
|
||||
if not context.revise or not first.draft or not second.draft or first.plan.ending != 'connected' or first.plan.passage_id != second.plan.passage_id:
|
||||
return first, second
|
||||
incoming = incoming if isinstance(incoming, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=incoming)
|
||||
harmony = generator.solo.harmonic.Harmony(choices)
|
||||
seam = first.plan.end
|
||||
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
|
||||
combined = (*emitted_notes(first), *emitted_notes(second))
|
||||
draft = dataclasses.replace(first.draft, end=second.plan.end, notes=(*first.draft.notes, *second.draft.notes), transformations=(*first.draft.transformations, *second.draft.transformations), structural=tuple(dict.fromkeys((*first.draft.structural, *second.draft.structural))), protected=(*first.draft.protected, *second.draft.protected), joins=(*first.draft.joins, seam, *second.draft.joins))
|
||||
joint = dataclasses.replace(first, decisions=(generator.solo.types.Decision(first.plan.start, second.plan.end, 'provisional phrase pair', 'continue', combined),), draft=draft)
|
||||
candidates = [(second.score + generator.solo.drafts.relationship_features(draft, combined)['join_coherence'], first, second)]
|
||||
left_ids = {n.event_id for n in emitted_notes(first)}
|
||||
for notes, source in window_variants(joint, seam - width, seam + width, harmony, sequences, 'join')[:generator.solo.policy.DEFAULT.join_alternatives]:
|
||||
left = tuple(n for n in notes if n.event_id in left_ids)
|
||||
right = tuple(n for n in notes if n.event_id not in left_ids)
|
||||
if any(n.end > seam for n in left) or any(n.start < seam for n in right):
|
||||
continue
|
||||
edit = generator.solo.types.DraftEdit('phrase_join', seam - width, seam + width, tuple(n.event_id for n in combined if n not in notes), tuple(n.event_id for n in notes if n not in combined), source)
|
||||
if not edit.removed:
|
||||
continue
|
||||
revised_left = checked_phrase(first.plan, first.draft, left, first.state.treatments, incoming, harmony, tempo, (*first.edits, edit))
|
||||
if not revised_left:
|
||||
continue
|
||||
revised_right = checked_phrase(second.plan, second.draft, right, second.state.treatments, revised_left.state, harmony, tempo, (*second.edits, edit))
|
||||
context.diagnostics['pair_revision_attempts'] = context.diagnostics.get('pair_revision_attempts', 0) + 1
|
||||
if revised_right:
|
||||
realized = (*emitted_notes(revised_left), *emitted_notes(revised_right))
|
||||
score = revised_right.score + generator.solo.drafts.relationship_features(draft, realized)['join_coherence']
|
||||
candidates.append((score, revised_left, revised_right))
|
||||
_, left, right = min(candidates, key=lambda item: (item[0], len(item[1].edits) + len(item[2].edits)))
|
||||
if left is not first:
|
||||
context.diagnostics['pair_revisions_accepted'] = context.diagnostics.get('pair_revisions_accepted', 0) + 1
|
||||
return left, right
|
||||
|
||||
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
|
||||
context = context if context is not None else SearchContext()
|
||||
key = (plan, previous)
|
||||
# Keep resources alive while identity-keyed cache entries exist. A later
|
||||
# library cannot accidentally reuse the identity of a collected object.
|
||||
context.resources[id(sequences)] = sequences
|
||||
context.resources[id(rhythms)] = rhythms
|
||||
pool_key = (plan, previous, generator.solo.policy.stable_rank('harmonic-cache', choices), tempo, id(sequences), id(rhythms))
|
||||
key = (*pool_key, context.revise)
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits'] += 1
|
||||
return context.results[key]
|
||||
started = time.monotonic()
|
||||
context.diagnostics['searches'] += 1
|
||||
harmony = generator.solo.harmonic.Harmony(choices)
|
||||
incoming = previous if isinstance(previous, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=previous)
|
||||
continuing = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
|
||||
initial = incoming if continuing else generator.solo.types.ContinuationState(plan.passage_id, last=incoming.last, treatments=plan.treatments)
|
||||
backbones = plan.anchor_options or tuple([()])
|
||||
# A caller explicitly scheduling quotation owns its rhythm and pitches. Such
|
||||
# plans must provide compatible anchors; no automatic rewrite is permitted.
|
||||
states = [BeamState(0, (), dataclasses.replace(initial, backbone=i), initial.travel, initial.fidelity) for i in ([initial.backbone] if continuing else range(len(backbones)))]
|
||||
if plan not in context.pools:
|
||||
context.pools[plan] = cells(plan, measures, rhythms, choices)
|
||||
evaluation_plan = dataclasses.replace(plan, start=plan.passage_start if plan.passage_start is not None else plan.start, end=plan.passage_end or plan.end)
|
||||
last_error = ''
|
||||
for cell in context.pools[plan]:
|
||||
evaluation = generator.solo.scoring.Evaluation(evaluation_plan, choices, cell.end)
|
||||
following, pools = [], {}
|
||||
for state in states:
|
||||
music = state.music
|
||||
anchors = backbones[music.backbone]
|
||||
reference = generator.solo.motifs.reference(plan, cell.gesture, music.motifs)
|
||||
# Concrete results depend on pitch, fingering, motif, anchor progress,
|
||||
# rhythm history and unresolved treatments. Abstract rhythms alone cache
|
||||
# across unrelated incoming lines.
|
||||
pool_key = (music, anchors)
|
||||
if pool_key not in pools:
|
||||
pool = candidates(cell, plan, choices, sequences, music, anchors, harmony)
|
||||
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
|
||||
pools[pool_key] = generator.solo.sequences.diverse(pool, lambda c: (c.rank, c.source), lambda c: (len(c.notes), note_family(c.notes), bool(c.notes and c.notes[0].continuation)), CELL_LIMIT)
|
||||
for candidate in pools[pool_key]:
|
||||
context.diagnostics['evaluated_extensions'] += 1
|
||||
placed = generator.solo.placement.place(candidate.notes, tempo, music.last)
|
||||
if placed is None:
|
||||
continue
|
||||
comfort, notes = placed
|
||||
canonical = generator.solo.types.sounding_notes((*music.notes, *notes))
|
||||
completed = cell.end == (plan.passage_end or plan.end)
|
||||
harmonic = harmony.evaluate(canonical, candidate.treatments, until=cell.end, complete=completed)
|
||||
valid, error = generator.solo.harmonic.anchors_valid(anchors, canonical, cell.end, complete=completed, closure=plan.closure)
|
||||
if not harmonic.valid or not valid:
|
||||
last_error = harmonic.error or error
|
||||
continue
|
||||
if plan.target and (plan.target.latest < cell.end or cell.end == plan.end) and not target_ok(canonical, plan.target):
|
||||
last_error = 'legacy destination was not reached'
|
||||
continue
|
||||
motifs, fidelity = music.motifs, state.fidelity
|
||||
if cell.gesture and cell.end == cell.gesture.end:
|
||||
valid, mismatch, identity = generator.solo.motifs.complete(cell.gesture, reference, canonical, harmony)
|
||||
if not valid:
|
||||
last_error = f'{cell.gesture.id}: insufficient evidence of {cell.gesture.treatment} from {reference.id}'
|
||||
continue
|
||||
fidelity += (mismatch,)
|
||||
if identity:
|
||||
motifs += (identity,)
|
||||
if not evaluation.feasible(canonical):
|
||||
last_error = 'planned activity, sustain or space not fulfilled'
|
||||
continue
|
||||
features = evaluation.features(canonical)
|
||||
features['fidelity'] = (sum(fidelity) + candidate.fidelity) / (len(fidelity) + 1)
|
||||
features['harmonic_exposure'] = harmonic.exposure
|
||||
features['connection'] = min(1, abs(canonical[0].pitch.number() - incoming.last.pitch.number()) / 12) if canonical and incoming.last and not continuing else 0
|
||||
features['movement'] = min(1, (state.travel + comfort) / max(1, len(canonical)) / 2000)
|
||||
if cell.gesture:
|
||||
recent = tuple(n for n in canonical if cell.gesture.start <= n.start < cell.gesture.end)
|
||||
intended_repeats = sum(a.pitch == b.pitch == c.pitch and a.start == x.start - cell.gesture.start and b.start == y.start - cell.gesture.start and c.start == z.start - cell.gesture.start for a, b, c in zip(reference.notes, reference.notes[1:], reference.notes[2:]) for x, y, z in zip(recent, recent[1:], recent[2:]) if x.pitch == y.pitch == z.pitch)
|
||||
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(canonical) - 2))
|
||||
total = generator.solo.scoring.value(features)
|
||||
last = canonical[-1] if canonical else music.last
|
||||
progress = tuple(a.id for a in anchors if generator.solo.harmonic.anchor_note(a, canonical))
|
||||
direction = (last.pitch.number() > music.last.pitch.number()) - (last.pitch.number() < music.last.pitch.number()) if last and music.last else music.direction
|
||||
carried = generator.solo.types.ContinuationState(plan.passage_id, canonical, motifs, candidate.treatments, progress, direction, last, music.backbone, fidelity, state.travel + comfort)
|
||||
decision = generator.solo.types.Decision(cell.start, cell.end, candidate.source, candidate.treatment, notes, candidate.fidelity, cell.gesture.id if cell.gesture else '', reference.id)
|
||||
following.append(BeamState(total, (*state.decisions, decision), carried, state.travel + comfort, fidelity))
|
||||
if not following:
|
||||
raise SearchExhausted(f'{plan.id}, beats {cell.start + 1}-{cell.end + 1}: bounded solo search exhausted; {last_error or "no eligible continuation"}; {BEAM_WIDTH} states / {CELL_LIMIT} extensions (not proof that the passage is unplayable)')
|
||||
|
||||
def rank(state):
|
||||
return state.score, tuple((n.pitch, n.position, n.start, n.end) for n in state.music.notes)
|
||||
|
||||
unique = {}
|
||||
for state in sorted(following, key=rank):
|
||||
unique.setdefault(state.music, state)
|
||||
# A rolling one-gesture window keeps the longer plan bounded. Relevant
|
||||
# motif/anchor/treatment state survives; a matching last fret is not identity.
|
||||
limit = generator.solo.policy.DEFAULT.alternatives if cell.gesture and cell.end == cell.gesture.end else BEAM_WIDTH
|
||||
states = list(generator.solo.sequences.diverse(unique.values(), rank, lambda s: (s.music.backbone, note_family(s.music.notes[-4:]), s.music.last.pitch if s.music.last else None), limit))
|
||||
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
|
||||
selected = generator.solo.sequences.diverse(states, lambda s: (s.score, tuple(n.pitch for n in s.music.notes)), lambda s: (s.music.backbone, note_family(s.music.notes[-4:])), generator.solo.policy.DEFAULT.alternatives)
|
||||
for state in selected:
|
||||
family = note_family(state.music.notes[-4:])
|
||||
harmony = generator.solo.harmonic.Harmony(choices)
|
||||
plan = legacy_target_plan(plan, choices)
|
||||
if pool_key in context.pools:
|
||||
selected = context.pools[pool_key]
|
||||
context.diagnostics['reused_draft_pools'] = context.diagnostics.get('reused_draft_pools', 0) + 1
|
||||
else:
|
||||
constructions = generator.solo.drafts.rhythms(plan, incoming, measures, choices, rhythms, context.batch)
|
||||
complete, failure = [], ''
|
||||
for rhythm in constructions:
|
||||
paths, error = draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, context)
|
||||
failure = error or failure
|
||||
context.diagnostics['generated_candidates'] += len(paths)
|
||||
for notes, cost, contracts in paths:
|
||||
family = note_family(notes)
|
||||
counts = context.diagnostics['candidate_families']
|
||||
counts[family] = counts.get(family, 0) + 1
|
||||
realizations = [notes]
|
||||
if notes and incoming.last and incoming.last.end == notes[0].start and incoming.last.pitch == notes[0].pitch and notes[0].role != 'quote' and not any(a.onset_required and a.earliest <= notes[0].start <= a.latest for a in rhythm.anchors):
|
||||
realizations.append((dataclasses.replace(notes[0], continuation=True), *notes[1:]))
|
||||
for actual in realizations:
|
||||
draft = make_draft(rhythm, actual, plan, harmony, incoming)
|
||||
phrase = checked_phrase(plan, draft, actual, contracts, incoming, harmony, tempo)
|
||||
if phrase:
|
||||
complete.append(dataclasses.replace(phrase, score=phrase.score + 0.1 * cost))
|
||||
if not complete:
|
||||
raise SearchExhausted(f'{plan.id}: bounded phrase draft search exhausted; {failure or "no complete draft satisfies timing, anchors, treatments and motif contracts"}; {len(constructions)} constructions / {BEAM_WIDTH} pitch states (not proof that the phrase is unplayable)')
|
||||
context.diagnostics['construction_seconds'] = context.diagnostics.get('construction_seconds', 0) + time.monotonic() - started
|
||||
selected = generator.solo.sequences.diverse(complete, lambda p: (p.score, p.draft.id, tuple(n.pitch for n in p.draft.notes)), lambda p: (p.draft.relationship, note_family(p.draft.notes), p.anchors[-1].duration if p.anchors else None), generator.solo.policy.DEFAULT.phrase_alternatives)
|
||||
selected = tuple(elaborate(p, incoming, harmony, sequences, tempo, context) for p in selected)
|
||||
context.pools[pool_key] = selected
|
||||
input_id = generator.solo.policy.stable_rank(plan.seed, (plan, previous, choices))[:16]
|
||||
pool_id = generator.solo.policy.stable_rank(plan.seed, tuple((p.draft.id, emitted_notes(p)) for p in selected))
|
||||
context.diagnostics.setdefault('draft_pool_fingerprints', {})[input_id] = pool_id
|
||||
result = []
|
||||
for phrase in selected:
|
||||
if context.revise:
|
||||
phrase = revise_joins(phrase, incoming, harmony, sequences, tempo, context)
|
||||
result.append(phrase)
|
||||
result = tuple(sorted(result, key=lambda p: (p.score, p.draft.id)))
|
||||
for phrase in result:
|
||||
family = note_family(emitted_notes(phrase))
|
||||
counts = context.diagnostics['surviving_families']
|
||||
counts[family] = counts.get(family, 0) + 1
|
||||
result = tuple(generator.solo.types.Phrase(plan, state.decisions, state.score, state.music, backbones[state.music.backbone]) for state in selected)
|
||||
context.results[key] = result
|
||||
return result
|
||||
|
||||
|
||||
@@ -11,13 +11,14 @@ import generator.scales.selection
|
||||
import generator.scales.timeline
|
||||
import generator.solo.types
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.drafts
|
||||
|
||||
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str:
|
||||
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))
|
||||
lines.append('Composition diagnostics: ' + json.dumps({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}, sort_keys=True))
|
||||
detail = structure(solo)
|
||||
for passage in detail['passages']:
|
||||
lines.append(f"Melodic passage {passage['id']}: {passage['start']}-{passage['end']}; phrases {', '.join(passage['phrases'])}")
|
||||
@@ -26,6 +27,13 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
|
||||
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}')
|
||||
if phrase.draft:
|
||||
draft = phrase.draft
|
||||
lines.append(f' Complete draft {draft.id}: {draft.relationship}; home {draft.home.id if draft.home else "none"}; {len(draft.notes)} attacks; rests {draft.rests()}')
|
||||
for transform in draft.transformations:
|
||||
lines.append(f' Development {transform.id}: {transform.operation}; {transform.start}-{transform.end}; parent {transform.parent}; home {transform.home}; preserves {transform.preserves}')
|
||||
for edit in phrase.edits:
|
||||
lines.append(f' Revision {edit.kind}: {edit.start}-{edit.end}; {edit.source}; replaced {edit.removed}; realized {edit.added}')
|
||||
for passage in plan.passages:
|
||||
lines.append(f' Activity {passage.start}-{passage.end}: {passage.intent}; attacks/beat {float(passage.activity):.3f}; occupancy {float(passage.occupancy):.2f}')
|
||||
for landmark in plan.landmarks:
|
||||
@@ -81,5 +89,8 @@ def structure(solo):
|
||||
event = generator.solo.harmonic.anchor_note(anchor, notes)
|
||||
anchors.append({**rational(dataclasses.asdict(anchor)), 'pitch': anchor.pitch.label(), 'completed': event is not None, 'actual_start': str(event.start) if event else None, 'actual_end': str(event.end) if event else None})
|
||||
motifs = tuple(m for m in phrase.state.motifs if phrase.plan.start <= m.source_start < phrase.plan.end) if phrase.state else ()
|
||||
phrases.append({'id': phrase.plan.id, 'passage': phrase.plan.passage_id, 'start': str(phrase.plan.start), 'end': str(phrase.plan.end), 'ending': phrase.plan.ending, 'closure': phrase.plan.closure, 'ending_completed': valid, 'ending_error': reason, 'anchors': anchors, 'motif_lineage': rational([dataclasses.asdict(m) for m in motifs])})
|
||||
entry = {'id': phrase.plan.id, 'passage': phrase.plan.passage_id, 'start': str(phrase.plan.start), 'end': str(phrase.plan.end), 'ending': phrase.plan.ending, 'closure': phrase.plan.closure, 'ending_completed': valid, 'ending_error': reason, 'anchors': anchors, 'motif_lineage': rational([dataclasses.asdict(m) for m in motifs])}
|
||||
if phrase.draft:
|
||||
entry.update({'draft': rational(dataclasses.asdict(phrase.draft)), 'draft_rests': rational(phrase.draft.rests()), 'edits': rational([dataclasses.asdict(e) for e in phrase.edits]), 'relationships': generator.solo.drafts.relationship_features(phrase.draft, tuple(n for d in phrase.decisions for n in d.notes)), 'home': phrase.state.home.id if phrase.state and phrase.state.home else None, 'current_development': phrase.state.development.id if phrase.state and phrase.state.development else None})
|
||||
phrases.append(entry)
|
||||
return {'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'harmonic_roles': harmony.describe(solo.notes(), contracts), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
|
||||
|
||||
@@ -33,6 +33,7 @@ class Note:
|
||||
role: str = 'continuation'
|
||||
position: generator.fretboard.TabPosition | None = None
|
||||
continuation: bool = False
|
||||
event_id: str = ''
|
||||
|
||||
def sounding_notes(notes):
|
||||
"""Collapse explicitly continued attacks, never adjacent repeated pitches."""
|
||||
@@ -130,6 +131,57 @@ class ContinuationState:
|
||||
backbone: int = 0
|
||||
fidelity: tuple[float, ...] = ()
|
||||
travel: int = 0
|
||||
home: RealizedMotif | None = None
|
||||
development: RealizedMotif | None = None
|
||||
themes: tuple[RealizedMotif, ...] = ()
|
||||
selected_anchors: tuple[Anchor, ...] = ()
|
||||
ending: str = ''
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Transformation:
|
||||
id: str
|
||||
parent: str
|
||||
home: str
|
||||
operation: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
events: tuple[str, ...]
|
||||
rhythm: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
|
||||
directions: tuple[int, ...] = ()
|
||||
preserves: tuple[str, ...] = ('rhythm', 'opening_contour')
|
||||
source: str = ''
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class DraftEdit:
|
||||
kind: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
removed: tuple[str, ...]
|
||||
added: tuple[str, ...]
|
||||
source: str = ''
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MelodicDraft:
|
||||
id: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
notes: tuple[Note, ...]
|
||||
transformations: tuple[Transformation, ...]
|
||||
structural: tuple[str, ...]
|
||||
protected: tuple[str, ...]
|
||||
anchors: tuple[Anchor, ...]
|
||||
home: RealizedMotif | None
|
||||
relationship: str
|
||||
joins: tuple[fractions.Fraction, ...] = ()
|
||||
def rests(self):
|
||||
cursor, result = self.start, []
|
||||
for note in self.notes:
|
||||
if note.start > cursor:
|
||||
result.append((cursor, note.start))
|
||||
cursor = max(cursor, note.end)
|
||||
if cursor < self.end:
|
||||
result.append((cursor, self.end))
|
||||
return tuple(result)
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MelodicPassagePlan:
|
||||
@@ -190,6 +242,8 @@ class Phrase:
|
||||
score: float = 0
|
||||
state: ContinuationState | None = None
|
||||
anchors: tuple[Anchor, ...] = ()
|
||||
draft: MelodicDraft | None = None
|
||||
edits: tuple[DraftEdit, ...] = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Solo:
|
||||
|
||||
Reference in New Issue
Block a user