Comp
This commit is contained in:
@@ -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, revise=True, contexts=None):
|
||||
def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None, references=(), stage='elaborated'):
|
||||
"""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, revise=True,
|
||||
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, revise=revise, context=(contexts or {}).get(part))
|
||||
solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial, revise=revise, references=references, stage=stage, context=(contexts or {}).get(part))
|
||||
if solos is not None:
|
||||
solos.append(solo)
|
||||
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
|
||||
|
||||
@@ -28,6 +28,8 @@ import generator.solo.arrangement
|
||||
import generator.solo.report
|
||||
import generator.solo.sequences
|
||||
import generator.solo.realization
|
||||
import generator.solo.references
|
||||
import generator.solo.harmonic
|
||||
|
||||
STAGES = ('baseline', 'A', 'B', 'C', 'D')
|
||||
|
||||
@@ -81,12 +83,21 @@ def capture(root, stage, libraries=None, contexts=None):
|
||||
if destination.exists():
|
||||
raise ValueError(f'{destination}: snapshot already exists; choose a new output root')
|
||||
rhythms, scales, sequences = libraries or resources()
|
||||
result = {'stage': stage, 'implementation': fingerprint(), 'songs': {}}
|
||||
reference_library = generator.solo.references.load_library(pathlib.Path('data/lead_phrases'))
|
||||
result = {'stage': stage, 'implementation': fingerprint(), 'reference_versions': [r.version for r in reference_library], 'songs': {}}
|
||||
try:
|
||||
result['policy'] = importlib.import_module('generator.solo.policy').describe()
|
||||
except ModuleNotFoundError:
|
||||
result['policy'] = {'baseline': True}
|
||||
for path in generator.parts.source_paths(pathlib.Path('data/songs')):
|
||||
source_hash = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
checkpoint = root / f'.{stage}_{path.stem}_checkpoint.json'
|
||||
if checkpoint.exists():
|
||||
saved = json.loads(checkpoint.read_text())
|
||||
if saved['implementation'] == result['implementation'] and saved.get('reference_versions') == result['reference_versions'] and saved['song']['source_sha256'] == source_hash:
|
||||
result['songs'][path.stem] = saved['song']
|
||||
result.setdefault('resumed_songs', []).append(path.stem)
|
||||
continue
|
||||
started = time.monotonic()
|
||||
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
|
||||
plans = generator.arrange.generate.plan_arrangement(document, rhythms)
|
||||
@@ -96,7 +107,7 @@ def capture(root, stage, libraries=None, contexts=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)
|
||||
generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared, references=reference_library)
|
||||
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)):
|
||||
@@ -123,8 +134,30 @@ def capture(root, stage, libraries=None, contexts=None):
|
||||
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)
|
||||
stages = {}
|
||||
for stage_name in ('initial', 'refined'):
|
||||
if not all(getattr(p, stage_name) for p in solo.phrases):
|
||||
continue
|
||||
notes = tuple(n for p in solo.phrases for n in getattr(p, stage_name).notes)
|
||||
placed = generator.solo.placement.place(notes, solo.tempo)
|
||||
if placed is None:
|
||||
raise ValueError(stage_name + ': selected theme cannot be placed')
|
||||
sounded = generator.solo.types.sounding_notes(placed[1])
|
||||
contracts = tuple(dict.fromkeys(t for p in solo.phrases for t in p.state.treatments))
|
||||
checked = generator.solo.harmonic.Harmony(solo.choices).evaluate(sounded, contracts, until=solo.end, complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError(stage_name + ': ' + checked.error)
|
||||
view = excerpt(parts[solo.part], first, count)
|
||||
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], stage_name + '_' + solo.part)
|
||||
generator.solo.notation.replace_region(view, 0, foreground)
|
||||
generator.model.validate_document(view)
|
||||
generator.fretboard.validate_document(view)
|
||||
stages[stage_name] = {'document': view, 'attacks': len(sounded), 'occupancy': float(sum(n.end - n.start for n in sounded) / solo.end), 'harmonic_exposure': checked.exposure}
|
||||
song['solos'][solo.part]['lead_stages'] = stages
|
||||
print(path.stem, solo.part, json.dumps({k: v for k, v in song['solos'][solo.part]['metrics'].items() if k != 'search'}), flush=True)
|
||||
result['songs'][path.stem] = song
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
checkpoint.write_text(json.dumps({'implementation': result['implementation'], 'reference_versions': result['reference_versions'], 'song': song}) + '\n')
|
||||
destination.mkdir(parents=True)
|
||||
(destination / 'snapshot.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||
|
||||
|
||||
@@ -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, revise=True, context=None):
|
||||
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated'):
|
||||
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)
|
||||
@@ -31,6 +31,8 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
|
||||
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 = context if context is not None else generator.solo.realization.SearchContext()
|
||||
context.revise = revise
|
||||
context.references = references
|
||||
context.stage = stage
|
||||
|
||||
def realize(plan, previous):
|
||||
for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1):
|
||||
|
||||
+66
-22
@@ -14,6 +14,7 @@ import generator.solo.policy
|
||||
import generator.solo.scoring
|
||||
import generator.solo.sequences
|
||||
import generator.solo.types
|
||||
import generator.solo.references
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
@@ -77,7 +78,7 @@ def templates(plan, reference, library, meter):
|
||||
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: (abs(len(item[1]) / float(item[2]) - float(plan.activity)) > 0.75, generator.solo.policy.stable_rank(plan.seed, item)),
|
||||
lambda item: (len(item[1]), bool(item[1][0][0]), item[2]), 4)
|
||||
result.extend(ranked)
|
||||
return tuple(result)
|
||||
@@ -127,17 +128,51 @@ def ending_options(anchors, plan, choices):
|
||||
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)
|
||||
passage_end = plan.passage_end or plan.end
|
||||
active = [c for c in choices if c.scale and c.span.start < passage_end and c.span.end > plan.start]
|
||||
if not active:
|
||||
return tuple([anchors])
|
||||
# Missing harmony is mandatory space. Retiming an ordinary ending must not
|
||||
# move its destination into a trailing N.C. span.
|
||||
harmonic_end = min(passage_end, active[-1].span.end)
|
||||
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))
|
||||
for release in (F(), F(1, 2), F(1)):
|
||||
finish = harmonic_end - release
|
||||
last = next((c for c in choices if c.span.start < finish <= c.span.end), None)
|
||||
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, hold_until=finish)
|
||||
result.append(tuple(changed if a.id == terminal.id else a for a in anchors))
|
||||
return tuple(result) or tuple([anchors])
|
||||
|
||||
def ending_plan(plan, anchors):
|
||||
if plan.target or plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
|
||||
return plan
|
||||
terminal = next((a for a in anchors if a.role == 'terminal'), None)
|
||||
if terminal is None or terminal.hold_until is None:
|
||||
return plan
|
||||
finish = plan.passage_end or plan.end
|
||||
release = finish - terminal.hold_until
|
||||
# The candidate chooses where arrival begins. Earlier portions remain active.
|
||||
spans = []
|
||||
for span in plan.passages:
|
||||
low, high = span.start, min(span.end, terminal.earliest)
|
||||
if low < high:
|
||||
spans.append(dataclasses.replace(span, end=high, intent='active', occupancy=F(str(generator.solo.policy.DEFAULT.active_occupancy))))
|
||||
if not spans and plan.start < terminal.earliest:
|
||||
spans.append(generator.solo.types.ActivitySpan(plan.passage_start if plan.passage_start is not None else plan.start, terminal.earliest, 'active', plan.activity, F(str(generator.solo.policy.DEFAULT.active_occupancy))))
|
||||
arrival_length = terminal.hold_until - terminal.earliest
|
||||
desired = max(F(), plan.activity * (finish - (plan.passage_start if plan.passage_start is not None else plan.start)) - 1)
|
||||
expected = sum(s.activity * (s.end - s.start) for s in spans)
|
||||
spans = [dataclasses.replace(s, activity=min(F(2), s.activity * desired / expected) if expected else plan.activity) for s in spans]
|
||||
spans.append(generator.solo.types.ActivitySpan(terminal.earliest, terminal.hold_until, 'sustained_arrival', 1 / arrival_length, F(str(generator.solo.policy.DEFAULT.arrival_occupancy))))
|
||||
if release:
|
||||
spans.append(generator.solo.types.ActivitySpan(terminal.hold_until, finish, 'space', F(), F()))
|
||||
complete = plan.end == finish
|
||||
return dataclasses.replace(plan, release=release if complete else F(), passages=tuple(spans), ending=('breath' if release else 'sustain') if complete else 'connected')
|
||||
|
||||
def align_anchors(slots, anchors, plan):
|
||||
"""Choose arrival timing with the rhythm, before assigning any free pitches."""
|
||||
slots = list(slots)
|
||||
@@ -172,10 +207,12 @@ def construct(plan, reference, relationship, template, anchors, variant, choices
|
||||
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)]
|
||||
# Establish the idea before departing. Choose from the remaining musical
|
||||
# span instead of cycling a fixed list of transformations indefinitely.
|
||||
remaining = finish - cursor
|
||||
operation = relationship if index == 0 else 'repeat' if index == 1 and variant == 0 else 'answer' if remaining <= period else 'extend' if variant == 1 and index == 1 else 'recall'
|
||||
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)
|
||||
@@ -250,7 +287,7 @@ def construct(plan, reference, relationship, template, anchors, variant, choices
|
||||
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):
|
||||
def rhythms(plan, incoming, measures, choices, library, batch=0, reference_library=()):
|
||||
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')]
|
||||
@@ -262,6 +299,8 @@ def rhythms(plan, incoming, measures, choices, library, batch=0):
|
||||
references.append((plan.motif, 'contrast'))
|
||||
if incoming.themes and incoming.themes[0].id != incoming.home.id:
|
||||
references.append((incoming.themes[0], 'recall'))
|
||||
if plan.treatment not in ('quote', 'exact_quote', 'rhythmic_reference') and not continuing:
|
||||
references.extend((motif, 'contrast' if incoming.home else 'statement') for example in reference_library for motif in example.windows())
|
||||
elapsed = F()
|
||||
meter = measures[0]['time_signature']
|
||||
for measure in measures:
|
||||
@@ -273,28 +312,29 @@ def rhythms(plan, incoming, measures, choices, library, batch=0):
|
||||
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]
|
||||
if isinstance(reference, generator.solo.types.RealizedMotif) or reference.origin == 'accepted reference profile':
|
||||
available_templates = tuple([(reference.id, tuple((n.start, n.end) for n in reference.notes), reference.duration)])
|
||||
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)
|
||||
rhythm = construct(ending_plan(plan, ending), 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)
|
||||
expected = float(plan.activity * (plan.end - plan.start))
|
||||
# Broad rhythm feasibility only. Pitch/rhythm relationships are evaluated
|
||||
# after realization; one attack cannot eliminate an otherwise useful tune.
|
||||
broad = abs(len(rhythm.slots) - expected) > max(4, expected / 2)
|
||||
return broad, 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
|
||||
return rhythm.relationship, rhythm.groups[0].source, shape
|
||||
|
||||
limit = generator.solo.policy.DEFAULT.draft_frontier
|
||||
return generator.solo.sequences.diverse(result, rank, family, limit * (batch + 1))[limit * batch:]
|
||||
@@ -326,6 +366,9 @@ def contract_valid(draft, notes):
|
||||
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'
|
||||
for feature in draft.features:
|
||||
if generator.solo.references.feature_error(feature, local_sounding(notes)):
|
||||
return False, f'{feature.id}: selected identifying relationship changed'
|
||||
return True, ''
|
||||
|
||||
def relationship_features(draft, notes):
|
||||
@@ -338,8 +381,9 @@ def relationship_features(draft, notes):
|
||||
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'))
|
||||
reference = draft.reference_motif if transform.operation in ('statement', 'contrast') else draft.home
|
||||
if reference and transform.operation not in ('close', 'fragment', 'broaden_ending'):
|
||||
development.append(generator.solo.references.relationship(group, reference))
|
||||
joins = []
|
||||
for time in draft.joins:
|
||||
left = next((n for n in reversed(notes) if n.start < time), None)
|
||||
|
||||
@@ -52,13 +52,25 @@ class Harmony:
|
||||
def __init__(self, choices):
|
||||
self.choices = choices
|
||||
self.spans = tuple(Span(c.span.start, c.span.end, roles(c.span.chord), c.scale, generator.harmony.symbol(c.span.chord) if c.span.chord else 'N.C.') for c in choices)
|
||||
self._at = {}
|
||||
self._segments = {}
|
||||
self._members = {}
|
||||
self.evaluations = {}
|
||||
def at(self, time):
|
||||
return next((s for s in self.spans if s.start <= time < s.end), None)
|
||||
if time not in self._at:
|
||||
self._at[time] = next((s for s in self.spans if s.start <= time < s.end), None)
|
||||
return self._at[time]
|
||||
def segments(self, note):
|
||||
return tuple((s, max(note.start, s.start), min(note.end, s.end)) for s in self.spans if s.start < note.end and s.end > note.start)
|
||||
key = (note.start, note.end)
|
||||
if key not in self._segments:
|
||||
self._segments[key] = tuple((s, max(note.start, s.start), min(note.end, s.end)) for s in self.spans if s.start < note.end and s.end > note.start)
|
||||
return self._segments[key]
|
||||
def members(self, start, end):
|
||||
spans = [s for s in self.spans if s.start < end and s.end > start]
|
||||
return frozenset.intersection(*(s.roles.basic for s in spans)) if spans else frozenset()
|
||||
key = (start, end)
|
||||
if key not in self._members:
|
||||
spans = [s for s in self.spans if s.start < end and s.end > start]
|
||||
self._members[key] = frozenset.intersection(*(s.roles.basic for s in spans)) if spans else frozenset()
|
||||
return self._members[key]
|
||||
def function(self, number, span):
|
||||
pitch = number % 12
|
||||
return 'basic' if pitch in span.roles.basic else 'specified_extension' if pitch in span.roles.specified else 'mapped_motion' if span.scale and pitch in span.scale.pitches else 'outside_collection'
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Audition selected lead stages and accepted controls through the normal output path."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import html
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.musicxml_writer
|
||||
import generator.parts
|
||||
import generator.playback
|
||||
import generator.scales.timeline
|
||||
import generator.scales.selection
|
||||
import generator.solo.audition as audition
|
||||
import generator.solo.drafts as drafts
|
||||
import generator.solo.harmonic as harmonic
|
||||
import generator.solo.notation as notation
|
||||
import generator.solo.placement as placement
|
||||
import generator.solo.realization as realization
|
||||
import generator.solo.references as references
|
||||
import generator.solo.types as types
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
def document_for(template, notes, tempo, identifier):
|
||||
placed = placement.place(notes, tempo)
|
||||
if placed is None:
|
||||
raise ValueError(identifier + ': no valid guitar placement')
|
||||
document = copy.deepcopy(template)
|
||||
notation.replace_region(document, 0, notation.measures(document['staves'][0]['measures'], placed[1], identifier))
|
||||
generator.model.validate_document(document)
|
||||
generator.fretboard.validate_document(document)
|
||||
timeline = generator.playback.compile_timeline(document)
|
||||
expected = [(n.start, n.end - n.start, n.pitch.number()) for n in types.sounding_notes(placed[1])]
|
||||
actual = [(generator.model.fraction(e['onset']), generator.model.fraction(e['duration']), generator.model.fraction(e['pitch'])) for e in timeline['events']]
|
||||
if actual != expected:
|
||||
raise ValueError(identifier + ': notation/playback changed the sounding melody')
|
||||
return document
|
||||
|
||||
def controls(song, libraries):
|
||||
"""Literal reference injection is restricted to this diagnostic, never production."""
|
||||
_, scales, sequences = libraries
|
||||
reference, = references.load_library(pathlib.Path('data/lead_phrases'))
|
||||
solo = song['solos']['acoustic']
|
||||
measures = song['parts']['acoustic']['staves'][0]['measures'][solo['first']:solo['first'] + solo['count']]
|
||||
choices = generator.scales.selection.select(generator.scales.timeline.read({'measures': measures}), generator.scales.timeline.Solo(F(), F(solo['length']), 'control'), scales)
|
||||
harmony = harmonic.Harmony(choices)
|
||||
template = audition.excerpt(song['parts']['acoustic'], solo['first'], 8)
|
||||
result = []
|
||||
for label, source in (('plain', reference.notes), ('runs', reference.decorated)):
|
||||
checked = harmony.evaluate(source, until=reference.duration, complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError('accepted control: ' + checked.error)
|
||||
theme = types.Motif(reference.id, source, reference.duration, tuple(c for _, c in reference.harmony), 'accepted control')
|
||||
groups = tuple(types.Transformation(f'control_{i}', reference.id, 'control_0', 'statement' if i == 0 else 'answer', F(i * 16), F((i + 1) * 16), tuple(n.event_id for n in source if i * 16 <= n.start < (i + 1) * 16), tuple((n.start - i * 16, n.end - i * 16) for n in source if i * 16 <= n.start < (i + 1) * 16), preserves=(), source=reference.id) for i in range(2))
|
||||
final = source[-1]
|
||||
terminal = types.Anchor('accepted_terminal', final.start, final.start, final.pitch, frozenset({final.pitch.number() % 12}), (55, 79), final.end - final.start, 'terminal', 'Dm', hold_until=final.end)
|
||||
home = realization.make_identity('control_0', reference.id, source[:len(groups[0].events)], F(), F(16), 'statement', harmony)
|
||||
draft = types.MelodicDraft('accepted_' + label, F(), reference.duration, source, groups, (final.event_id,), (), (terminal,), home, 'statement', (F(16),), references.identifying_features(source, reference.id), reference.id, theme)
|
||||
plan = types.PhrasePlan('accepted_' + label, F(), reference.duration, theme, 'statement', 'opening_reference', None, (55, 79), F(9, 8), F(1), passage_id='control', passage_start=F(), passage_end=reference.duration)
|
||||
incoming = types.ContinuationState('')
|
||||
phrase = realization.checked_phrase(plan, draft, source, (), incoming, harmony, F(solo['tempo']))
|
||||
if phrase is None:
|
||||
raise ValueError('accepted control rejected by phrase validation')
|
||||
context = realization.SearchContext()
|
||||
refined = realization.refine(phrase, (phrase,), incoming, harmony, sequences, F(solo['tempo']), context)
|
||||
elaborated = realization.elaborate(refined, incoming, harmony, sequences, F(solo['tempo']), context)
|
||||
for stage, notes in (('injected', source), ('processed', realization.emitted_notes(elaborated))):
|
||||
identifier = 'accepted_' + label + '_' + stage
|
||||
checked = harmony.evaluate(notes, until=reference.duration, complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError(identifier + ': ' + checked.error)
|
||||
result.append({'id': identifier, 'document': document_for(template, notes, F(solo['tempo']), identifier), 'attacks': len(notes), 'harmonic_exposure': checked.exposure, 'changed_events': sum((a.start, a.end, a.pitch) != (b.start, b.end, b.pitch) for a, b in zip(source, notes)) + abs(len(source) - len(notes)), 'edits': [e.kind for e in elaborated.edits] if stage == 'processed' else [], 'acceptance': reference.acceptance, 'reference_version': reference.version})
|
||||
return result
|
||||
|
||||
def render(root):
|
||||
baseline = json.loads((root / 'baseline/snapshot.json').read_text())
|
||||
current = json.loads((root / 'elaborated/snapshot.json').read_text())
|
||||
cache, clips = {}, []
|
||||
gain = 0.4 # Shared audition gain; every mixed clip is checked for clipping.
|
||||
manifest = {'implementation': current['implementation'], 'baseline_implementation': baseline['implementation'], 'policy': current['policy'], 'shared_gain': gain, 'clips': clips, 'stage_semantics': 'Initial and refined are intermediate versions of the selected production branches, with the same incoming context. They are not independently rerun compositions.'}
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Lead melody comparisons</title><style>body{font:16px system-ui;max-width:1500px;margin:2rem auto;padding:1rem}table{border-collapse:collapse}td,th{padding:.6rem;border-bottom:1px solid #ccc;text-align:left}audio{width:240px}small{display:block}</style><h1>Lead melody comparisons</h1><p>Compare the tune before and after refinement, then hear whether the runs preserve it. All fixed mixes use the original backing, identical tempo and one shared gain. Solo-only clips remove the backing. Initial/refined exports follow the selected production branches; they are not independent searches.</p><p>These metrics verify behavior, not musical quality. Listen for a recognizable tune, prepared destinations, and runs that belong to the tune. <a href="manifest.json">Diagnostics manifest</a>.</p>']
|
||||
|
||||
def clip(prefix, foreground, tempo, backing=()):
|
||||
path = root / (prefix + '.wav')
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
samples = audition.mix((foreground, *backing), tempo, root, cache)
|
||||
peak = max(map(abs, samples), default=0) * gain
|
||||
if peak >= 1:
|
||||
raise ValueError(prefix + ': shared audition gain would clip')
|
||||
generator.playback.write_pcm(path, (value * gain for value in samples))
|
||||
clips.append({'path': path.relative_to(root).as_posix(), 'tempo': str(tempo), 'peak': peak, 'attacks': len(generator.playback.compile_timeline(foreground)['events'])})
|
||||
return path.relative_to(root).as_posix()
|
||||
|
||||
for name, song in current['songs'].items():
|
||||
old = baseline['songs'][name]
|
||||
if old['source_sha256'] != song['source_sha256']:
|
||||
raise ValueError(name + ': source changed during comparison')
|
||||
for part, solo in song['solos'].items():
|
||||
other = 'electric' if part == 'acoustic' else 'acoustic'
|
||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part) + '</h2><table><tr><th>Excerpt</th><th>Baseline</th><th>Initial tune</th><th>Refined tune</th><th>With runs</th></tr>')
|
||||
for window, offset, count in (('opening8', 0, 8), ('closing4', solo['count'] - 4, 4), ('full', 0, solo['count'])):
|
||||
first = solo['first'] + offset
|
||||
backing = tuple(audition.excerpt(old['parts'][p], first, count) for p in (other, 'vocal'))
|
||||
body.append('<tr><th>' + window + '</th>')
|
||||
for stage in ('baseline', 'initial', 'refined', 'elaborated'):
|
||||
foreground = audition.excerpt((old if stage == 'baseline' else song)['parts'][part], first, count) if stage in ('baseline', 'elaborated') else audition.excerpt(solo['lead_stages'][stage]['document'], offset, count)
|
||||
prefix = f'{stage}/{name}_{part}_{window}'
|
||||
path = clip(prefix + '_fixed', foreground, F(solo['tempo']), backing)
|
||||
solo_path = clip(prefix + '_solo', foreground, F(solo['tempo']))
|
||||
generator.musicxml_writer.write_musicxml(root / (prefix + '.musicxml'), foreground)
|
||||
body.append(f'<td><audio controls preload="none" src="{path}"></audio><small><a href="{solo_path}">Solo only</a> · <a href="{prefix}.musicxml">MusicXML</a></small></td>')
|
||||
if stage == 'elaborated':
|
||||
clip(prefix + '_responsive', foreground, F(solo['tempo']), tuple(audition.excerpt(song['parts'][p], first, count) for p in (other, 'vocal')))
|
||||
body.append('</tr>')
|
||||
body.append('</table>')
|
||||
prefix = f'elaborated/{name}_{part}'
|
||||
(root / (prefix + '.txt')).write_text(solo['report'])
|
||||
(root / (prefix + '.json')).write_text(json.dumps(solo['structure'], indent=2) + '\n')
|
||||
body.append(f'<p><a href="{prefix}.txt">Passage report</a> · <a href="{prefix}.json">Anchors, motif lineage, stages and routes</a> · <a href="{prefix}_full_responsive.wav">Full solo with responsive backing</a></p>')
|
||||
print('Rendered', name, part, flush=True)
|
||||
song = current['songs']['autumn_leaves']
|
||||
solo = song['solos']['acoustic']
|
||||
backing = tuple(audition.excerpt(baseline['songs']['autumn_leaves']['parts'][p], solo['first'], 8) for p in ('electric', 'vocal'))
|
||||
body.append('<h2>Accepted reference controls</h2><p>The exact accepted notes enter the ordinary validation, guitar placement, notation and playback path. Processed controls additionally run refinement and elaboration. Literal injection is confined to these controls.</p>')
|
||||
results = controls(song, audition.resources())
|
||||
manifest['controls'] = [{k: v for k, v in r.items() if k != 'document'} for r in results]
|
||||
for control in results:
|
||||
prefix = 'controls/' + control['id']
|
||||
path = clip(prefix + '_fixed', control['document'], F(solo['tempo']), backing)
|
||||
single = clip(prefix + '_solo', control['document'], F(solo['tempo']))
|
||||
generator.musicxml_writer.write_musicxml(root / (prefix + '.musicxml'), control['document'])
|
||||
body.append(f'<p>{control["id"]}: <audio controls preload="none" src="{path}"></audio> <a href="{single}">Solo only</a> · <a href="{prefix}.musicxml">MusicXML</a></p>')
|
||||
(root / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_lead_auditions'))
|
||||
action = parser.add_mutually_exclusive_group(required=True)
|
||||
action.add_argument('--capture', action='store_true')
|
||||
action.add_argument('--render', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.capture:
|
||||
audition.capture(args.output, 'elaborated')
|
||||
else:
|
||||
render(args.output)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -86,15 +86,13 @@ def activity_passages(start, end, release, activity, shape=0):
|
||||
boundaries = sorted({start, music_end} | {start + fractions.Fraction(round(float((end - start) * i / 4) * 2), 2) for i in (1, 2, 3) if start < start + fractions.Fraction(round(float((end - start) * i / 4) * 2), 2) < music_end})
|
||||
weights = ((1, 1.6, 1.1), (0.8, 1.7, 1.2), (1.5, 1.4, 0.8))[shape % 3]
|
||||
spans = list(zip(boundaries, boundaries[1:]))
|
||||
arrival_rate = fractions.Fraction(3, 8)
|
||||
desired = max(0, activity * (end - start) - arrival_rate * (spans[-1][1] - spans[-1][0]))
|
||||
denominator = sum(float(b - a) * weights[min(i, 2)] for i, (a, b) in enumerate(spans[:-1]))
|
||||
desired = activity * (end - start)
|
||||
denominator = sum(float(b - a) * weights[min(i, 2)] for i, (a, b) in enumerate(spans))
|
||||
result = []
|
||||
for i, (a, b) in enumerate(spans):
|
||||
arrival = i == len(spans) - 1
|
||||
rate = arrival_rate if arrival else fractions.Fraction(str(min(2, float(desired) * weights[min(i, 2)] / max(denominator, 0.001)))).limit_denominator(1000)
|
||||
occupancy = generator.solo.policy.DEFAULT.arrival_occupancy if arrival else generator.solo.policy.DEFAULT.active_occupancy
|
||||
result.append(generator.solo.types.ActivitySpan(a, b, 'sustained_arrival' if arrival else 'active', rate, fractions.Fraction(str(occupancy))))
|
||||
rate = fractions.Fraction(str(min(2, float(desired) * weights[min(i, 2)] / max(denominator, 0.001)))).limit_denominator(1000)
|
||||
occupancy = generator.solo.policy.DEFAULT.active_occupancy
|
||||
result.append(generator.solo.types.ActivitySpan(a, b, 'active', rate, fractions.Fraction(str(occupancy))))
|
||||
if release:
|
||||
result.append(generator.solo.types.ActivitySpan(music_end, end, 'space', fractions.Fraction(), fractions.Fraction()))
|
||||
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), ('draft_preservation', 3), ('development', 2), ('direction', 1), ('join_coherence', 2), ('ending_integration', 2))
|
||||
weights: tuple[tuple[str, float], ...] = (('activity', 4), ('occupancy', 4), ('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
|
||||
@@ -26,7 +26,11 @@ class Policy:
|
||||
phrase_alternatives: int = 8
|
||||
join_alternatives: int = 8
|
||||
revision_sweeps: int = 2
|
||||
revision_joins: int = 2
|
||||
refinement_alternatives: int = 4
|
||||
join_window: float = 2.0
|
||||
activity_deadband: float = 1.0
|
||||
occupancy_deadband: float = 0.1
|
||||
|
||||
# Preserve the former default root seeds while decoupling them from policy edits.
|
||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||
|
||||
+190
-72
@@ -6,6 +6,8 @@ import functools
|
||||
import time
|
||||
|
||||
import generator.solo.drafts
|
||||
import generator.solo.references
|
||||
import generator.solo.routes
|
||||
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
@@ -354,6 +356,8 @@ class SearchContext:
|
||||
resources: dict = dataclasses.field(default_factory=dict)
|
||||
revise: bool = True
|
||||
batch: int = 0
|
||||
references: tuple = ()
|
||||
stage: str = 'elaborated'
|
||||
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):
|
||||
@@ -421,19 +425,9 @@ def draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, conte
|
||||
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
|
||||
# Theme invention follows a complete paired rhythmic/interval idea.
|
||||
# Authored sequence routes are instantiated as whole alternatives later.
|
||||
route = expected if not anchor else target
|
||||
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))
|
||||
@@ -443,14 +437,13 @@ def draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, conte
|
||||
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
|
||||
supported_leap = reference and abs(step) >= 3 and abs(pitch.number() - expected) <= 2
|
||||
score = cost + motion_cost + direction_cost + (0 if supported_leap else 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)
|
||||
@@ -489,10 +482,9 @@ def make_draft(rhythm, notes, plan, harmony, incoming):
|
||||
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')
|
||||
preserves = ()
|
||||
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))
|
||||
@@ -503,7 +495,7 @@ def make_draft(rhythm, notes, plan, harmony, incoming):
|
||||
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)
|
||||
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, reference=rhythm.reference.id, reference_motif=rhythm.reference)
|
||||
|
||||
def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edits=()):
|
||||
valid, _ = generator.solo.drafts.contract_valid(draft, notes)
|
||||
@@ -524,14 +516,17 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
|
||||
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)
|
||||
evaluation_key = (plan, draft.anchors)
|
||||
if evaluation_key not in harmony.evaluations:
|
||||
harmony.evaluations[evaluation_key] = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end)
|
||||
evaluation = harmony.evaluations[evaluation_key]
|
||||
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.pop('fidelity', None)
|
||||
features['harmonic_exposure'] = checked.exposure
|
||||
intended_repeats = 0
|
||||
if draft.home:
|
||||
@@ -589,32 +584,17 @@ def emitted_notes(phrase):
|
||||
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))
|
||||
protected = set((*phrase.draft.structural, *phrase.draft.protected, *(e for f in phrase.draft.features for e in f.events), *(n.event_id for r in phrase.routes for n in r.notes)))
|
||||
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)))
|
||||
# Compare complete routes through both fixed endpoints. Reject an invalid
|
||||
# path as a unit; never substitute nearest pitches for individual steps.
|
||||
for route in generator.solo.routes.candidates(notes[left:right + 1], protected, harmony, sequences, phrase.plan.register):
|
||||
candidates.append(((*notes[:left], *route.notes, *notes[right + 1:]), 'complete route ' + ' + '.join(route.sources)))
|
||||
for index in editable:
|
||||
note = notes[index]
|
||||
if index + 1 < len(notes) and notes[index + 1].start <= high:
|
||||
@@ -625,20 +605,6 @@ def window_variants(phrase, low, high, harmony, sequences, kind):
|
||||
# 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):
|
||||
@@ -648,7 +614,7 @@ def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, 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:
|
||||
if tuple((n.start, n.end, n.pitch, n.continuation) for n in notes) == tuple((n.start, n.end, n.pitch, n.continuation) for n in 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)
|
||||
@@ -656,16 +622,155 @@ def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind,
|
||||
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)
|
||||
viable.append(dataclasses.replace(revised, initial=phrase.initial, refined=phrase.refined, routes=phrase.routes))
|
||||
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 rebase_draft(draft, notes, harmony):
|
||||
"""Reassign edited events by absolute group time, retaining source contracts."""
|
||||
notes = tuple(dataclasses.replace(n, position=None) for n in notes)
|
||||
groups = []
|
||||
original_groups = {g.id: g for g in draft.transformations}
|
||||
for i, group in enumerate(draft.transformations):
|
||||
low = draft.start if i == 0 else group.start
|
||||
high = draft.transformations[i + 1].start if i + 1 < len(draft.transformations) else draft.end
|
||||
actual = tuple(n for n in notes if low <= n.start < high)
|
||||
if actual:
|
||||
parent = group.parent
|
||||
while parent in original_groups and parent not in {g.id for g in groups}:
|
||||
parent = original_groups[parent].parent
|
||||
groups.append(dataclasses.replace(group, parent=parent, events=tuple(n.event_id for n in actual), rhythm=group.rhythm if group.preserves else tuple((n.start - group.start, n.end - group.start) for n in actual)))
|
||||
home = draft.home
|
||||
if home and groups and home.id == groups[0].id:
|
||||
actual = tuple(n for n in notes if n.event_id in groups[0].events)
|
||||
home = make_identity(home.id, home.parent, actual, groups[0].start, groups[0].end, home.relationship, harmony)
|
||||
return dataclasses.replace(draft, notes=notes, transformations=tuple(groups), home=home)
|
||||
|
||||
def replace_idea(phrase, donor, low, high, incoming, harmony, tempo):
|
||||
"""A whole two-bar idea is editable until its identifying features are selected."""
|
||||
original = emitted_notes(phrase)
|
||||
if any(n.start < low < n.end or n.start < high < n.end for n in original):
|
||||
return None
|
||||
replacement = tuple(n for n in emitted_notes(donor) if low <= n.start and n.end <= high)
|
||||
if not replacement or tuple((n.start, n.end, n.pitch) for n in replacement) == tuple((n.start, n.end, n.pitch) for n in original if low <= n.start < high):
|
||||
return None
|
||||
protected = set((*phrase.draft.structural, *phrase.draft.protected))
|
||||
identifiers = {}
|
||||
for before in original:
|
||||
if before.event_id in protected and low <= before.start < high:
|
||||
after = next((n for n in replacement if (n.start, n.end, n.pitch) == (before.start, before.end, before.pitch)), None)
|
||||
if not after:
|
||||
return None
|
||||
identifiers[after.event_id] = before.event_id
|
||||
replacement = tuple(dataclasses.replace(n, event_id=identifiers.get(n.event_id, n.event_id + '_replacement'), position=None) for n in replacement)
|
||||
notes = tuple(n for n in original if n.end <= low) + replacement + tuple(n for n in original if n.start >= high)
|
||||
valid, _ = generator.solo.drafts.contract_valid(phrase.draft, notes)
|
||||
if not valid:
|
||||
return None
|
||||
draft = rebase_draft(phrase.draft, notes, harmony)
|
||||
edit = generator.solo.types.DraftEdit('replace_idea', low, high, tuple(n.event_id for n in original if low <= n.start < high), tuple(n.event_id for n in replacement), donor.draft.reference + '; ' + donor.draft.id)
|
||||
return checked_phrase(phrase.plan, draft, notes, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
|
||||
|
||||
def refine(phrase, pool, incoming, harmony, sequences, tempo, context, search=True):
|
||||
initial = phrase.draft
|
||||
for low in (phrase.plan.start, phrase.plan.start + 8) if search else ():
|
||||
high = min(phrase.plan.end, low + 8)
|
||||
if high - low < 8 or phrase.draft.protected:
|
||||
continue
|
||||
viable = [phrase]
|
||||
for donor in pool:
|
||||
if donor.draft.id == phrase.draft.id:
|
||||
continue
|
||||
context.diagnostics['idea_replacements_tried'] = context.diagnostics.get('idea_replacements_tried', 0) + 1
|
||||
revised = replace_idea(phrase, donor, low, high, incoming, harmony, tempo)
|
||||
if revised:
|
||||
viable.append(revised)
|
||||
chosen = min(viable, key=lambda p: (p.score, len(p.edits), p.draft.id))
|
||||
if chosen is not phrase:
|
||||
context.diagnostics['idea_replacements_selected'] = context.diagnostics.get('idea_replacements_selected', 0) + 1
|
||||
phrase = chosen
|
||||
if search and context.revise:
|
||||
phrase = revise_joins(phrase, incoming, harmony, sequences, tempo, context)
|
||||
notes = emitted_notes(phrase)
|
||||
draft = rebase_draft(phrase.draft, notes, harmony)
|
||||
# Only now commit a selected, inspectable relationship for elaboration.
|
||||
features = generator.solo.references.identifying_features(notes, draft.reference)
|
||||
draft = dataclasses.replace(draft, features=features)
|
||||
# Rebuild lineage after group removal or replacement, including when no run
|
||||
# is selected afterward. The introduced home is the actual refined tune.
|
||||
rebuilt = checked_phrase(phrase.plan, draft, notes, phrase.state.treatments, incoming, harmony, tempo, phrase.edits)
|
||||
if rebuilt is None:
|
||||
raise ValueError(phrase.plan.id + ': refined theme lost a committed contract')
|
||||
return dataclasses.replace(rebuilt, initial=initial, refined=draft)
|
||||
|
||||
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)
|
||||
protected = set((*phrase.draft.structural, *phrase.draft.protected, *(e for f in phrase.draft.features for e in f.events)))
|
||||
if phrase.draft.protected:
|
||||
return phrase
|
||||
# Elaborate selected approaches in the existing tune, at most two per phrase.
|
||||
notes = emitted_notes(phrase)
|
||||
windows = []
|
||||
for right in range(2, len(notes)):
|
||||
if notes[right].event_id not in protected and right != len(notes) - 1:
|
||||
continue
|
||||
left = next((i for i in range(right - 1, -1, -1) if notes[right].start - notes[i].start >= 4), None)
|
||||
if left is not None and notes[right].start - notes[left].start <= 4:
|
||||
windows.append((notes[left].start, notes[right].start))
|
||||
for low, high in windows[-2:]:
|
||||
if any(low < r.destination.end and high > r.notes[0].start for r in phrase.routes):
|
||||
continue
|
||||
notes = emitted_notes(phrase)
|
||||
left = next(i for i, n in enumerate(notes) if n.start == low)
|
||||
right = next(i for i, n in enumerate(notes) if n.start == high)
|
||||
patterns = [notes[left:right + 1]]
|
||||
# A full approach rhythm, preserving every committed event. Existing
|
||||
# silence remains a candidate; short-note motion is never compulsory.
|
||||
moving = []
|
||||
timepoint = low
|
||||
while timepoint < high:
|
||||
original = next((n for n in notes[left:right] if n.start == timepoint), None)
|
||||
end = min(high, timepoint + fractions.Fraction(1, 2))
|
||||
moving.append(dataclasses.replace(original, end=end, position=None) if original else generator.solo.types.Note(timepoint, end, notes[left].pitch, role='run', event_id=f'route_{phrase.draft.id}_{timepoint}'))
|
||||
timepoint = end
|
||||
patterns.append((*moving, notes[right]))
|
||||
for reference in context.references:
|
||||
for a, b in reference.runs:
|
||||
if high - low != b - a:
|
||||
continue
|
||||
profile = tuple(n for n in reference.decorated if a <= n.start and n.end <= b)
|
||||
timed = []
|
||||
for index, event in enumerate(profile):
|
||||
start, end = low + event.start - a, low + event.end - a
|
||||
original = next((n for n in notes[left:right] if n.start == start), None)
|
||||
timed.append(dataclasses.replace(original, end=end, position=None) if original else generator.solo.types.Note(start, end, notes[left].pitch, (reference.id,), 'run', event_id=f'profile_{phrase.draft.id}_{start}'))
|
||||
if timed:
|
||||
patterns.append((*timed, notes[right]))
|
||||
viable = [phrase]
|
||||
for pattern in dict.fromkeys(patterns):
|
||||
# Fixed events retain their duration and attack identity as well as pitch.
|
||||
if any(not any((n.event_id, n.start, n.end) == (p.event_id, p.start, p.end) for p in pattern) for n in notes[left:right + 1] if n.event_id in protected):
|
||||
continue
|
||||
for route in generator.solo.routes.candidates(pattern, protected, harmony, sequences, phrase.plan.register, 'approach the theme destination'):
|
||||
actual = (*notes[:left], *route.notes, *notes[right + 1:])
|
||||
context.diagnostics['complete_routes_tried'] = context.diagnostics.get('complete_routes_tried', 0) + 1
|
||||
if not generator.solo.drafts.contract_valid(phrase.draft, actual)[0]:
|
||||
continue
|
||||
draft = rebase_draft(phrase.draft, actual, harmony)
|
||||
edit = generator.solo.types.DraftEdit('complete_route', low, notes[right].end, tuple(n.event_id for n in notes[left:right + 1]), tuple(n.event_id for n in route.notes), ' + '.join(route.sources))
|
||||
revised = checked_phrase(phrase.plan, draft, actual, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
|
||||
if revised:
|
||||
# Retain the refined theme, not the ornamental version, as home.
|
||||
state = dataclasses.replace(revised.state, home=phrase.state.home, themes=phrase.state.themes)
|
||||
viable.append(dataclasses.replace(revised, state=state, initial=phrase.initial, refined=phrase.refined, routes=(*phrase.routes, route)))
|
||||
else:
|
||||
context.diagnostics['complete_routes_rejected'] = context.diagnostics.get('complete_routes_rejected', 0) + 1
|
||||
chosen = min(viable, key=lambda p: (p.score, len(p.edits), tuple(n.pitch for n in emitted_notes(p))))
|
||||
if chosen is not phrase:
|
||||
context.diagnostics['complete_routes_selected'] = context.diagnostics.get('complete_routes_selected', 0) + 1
|
||||
phrase = chosen
|
||||
context.diagnostics['elaboration_seconds'] = context.diagnostics.get('elaboration_seconds', 0) + time.monotonic() - started
|
||||
return phrase
|
||||
|
||||
@@ -674,7 +779,15 @@ def revise_joins(phrase, incoming, harmony, sequences, tempo, context):
|
||||
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:
|
||||
notes = emitted_notes(phrase)
|
||||
|
||||
def weakness(join):
|
||||
before = next((n for n in reversed(notes) if n.start < join), None)
|
||||
after = next((n for n in notes if n.start >= join), None)
|
||||
return (max(0, abs(after.pitch.number() - before.pitch.number()) - 4) + float(max(0, after.start - before.end))) if before and after else 0
|
||||
|
||||
joins = sorted(phrase.draft.joins, key=lambda j: (-weakness(j), j))[:generator.solo.policy.DEFAULT.revision_joins]
|
||||
for join in 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
|
||||
@@ -683,7 +796,7 @@ def revise_joins(phrase, incoming, harmony, sequences, tempo, context):
|
||||
|
||||
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:
|
||||
if first.routes or second.routes or first.refined or second.refined or 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)
|
||||
@@ -722,8 +835,8 @@ def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=No
|
||||
# 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)
|
||||
pool_key = (plan, previous, generator.solo.policy.stable_rank('harmonic-cache', choices), tempo, id(sequences), id(rhythms), tuple(r.version for r in context.references))
|
||||
key = (*pool_key, context.revise, context.stage)
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits'] += 1
|
||||
return context.results[key]
|
||||
@@ -736,10 +849,11 @@ def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=No
|
||||
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)
|
||||
constructions = generator.solo.drafts.rhythms(plan, incoming, measures, choices, rhythms, context.batch, context.references)
|
||||
complete, failure = [], ''
|
||||
for rhythm in constructions:
|
||||
paths, error = draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, context)
|
||||
candidate_plan = generator.solo.drafts.ending_plan(plan, rhythm.anchors)
|
||||
paths, error = draft_pitch_paths(rhythm, candidate_plan, choices, harmony, sequences, incoming, context)
|
||||
failure = error or failure
|
||||
context.diagnostics['generated_candidates'] += len(paths)
|
||||
for notes, cost, contracts in paths:
|
||||
@@ -750,29 +864,33 @@ def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=No
|
||||
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)
|
||||
draft = make_draft(rhythm, actual, candidate_plan, harmony, incoming)
|
||||
phrase = checked_phrase(candidate_plan, draft, actual, contracts, incoming, harmony, tempo)
|
||||
if phrase:
|
||||
complete.append(dataclasses.replace(phrase, score=phrase.score + 0.1 * cost))
|
||||
complete.append(phrase)
|
||||
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)
|
||||
for index, phrase in enumerate(selected):
|
||||
if context.stage == 'initial':
|
||||
phrase = dataclasses.replace(phrase, initial=phrase.draft)
|
||||
else:
|
||||
phrase = refine(phrase, selected, incoming, harmony, sequences, tempo, context, search=index < generator.solo.policy.DEFAULT.refinement_alternatives)
|
||||
if context.stage == 'elaborated':
|
||||
phrase = elaborate(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
|
||||
context.diagnostics.setdefault('complete_ideas', {})[input_id] = [{'id': p.draft.id, 'reference': p.draft.reference, 'score': round(p.score, 6), 'attacks': len(emitted_notes(p)), 'ending': p.plan.ending, 'hold': str(p.anchors[-1].duration) if p.anchors else '', 'edits': [e.kind for e in p.edits]} for p in result]
|
||||
context.results[key] = result
|
||||
return result
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Accepted, paired pitch/rhythm examples; production uses contextual profiles."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.solo.types as types
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ReferencePhrase:
|
||||
id: str
|
||||
version: str
|
||||
notes: tuple[types.Note, ...]
|
||||
decorated: tuple[types.Note, ...]
|
||||
duration: F
|
||||
tonic: int
|
||||
harmony: tuple[tuple[F, str], ...]
|
||||
features: tuple[tuple[str, F, F], ...]
|
||||
runs: tuple[tuple[F, F], ...]
|
||||
acceptance: str
|
||||
def windows(self):
|
||||
# Windows retain their parent and context; they are not separate approvals.
|
||||
result = []
|
||||
for length in (F(8), F(16)):
|
||||
for start in range(0, int(self.duration), int(length)):
|
||||
end = min(self.duration, start + length)
|
||||
selected = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in self.notes if start <= n.start and n.end <= end)
|
||||
if len(selected) >= 2:
|
||||
chords = tuple(chord for time, chord in self.harmony if start <= time < end)
|
||||
result.append(types.Motif(f'{self.id}:{start}:{end}', selected, end - start, chords, 'accepted reference profile'))
|
||||
return tuple(result)
|
||||
|
||||
def read(path):
|
||||
raw = path.read_bytes()
|
||||
data = json.loads(raw)
|
||||
if data['pitch_convention'] != 'sounding' or not data.get('acceptance'):
|
||||
raise ValueError(f'{path}: reference requires sounding pitches and explicit acceptance provenance')
|
||||
|
||||
def notes(variant):
|
||||
result, cursor = [], F()
|
||||
for i, event in enumerate(data['variants'][variant]['events']):
|
||||
start, duration = F(event['onset_beats']), F(event['duration_beats'])
|
||||
if start != cursor or duration <= 0:
|
||||
raise ValueError(f'{path}: reference events must fill exact sequential time')
|
||||
cursor = start + duration
|
||||
if event['pitch'] is None:
|
||||
continue
|
||||
name = event['pitch']
|
||||
pitch = types.Pitch(name[0].lower(), F(1 if '#' in name else -1 if 'b' in name else 0), int(name[-1]))
|
||||
if pitch.number() != event['midi_pitch']:
|
||||
raise ValueError(f'{path}: inconsistent sounding pitch {name}')
|
||||
result.append(types.Note(start, cursor, pitch, (data['id'],), event_id=f'{variant}_{i}'))
|
||||
return tuple(result), cursor
|
||||
|
||||
plain, duration = notes('lead_theme')
|
||||
decorated, other_duration = notes('lead_with_runs')
|
||||
if duration != other_duration or len(plain) != data['variants']['lead_theme']['attacks'] or len(decorated) != data['variants']['lead_with_runs']['attacks']:
|
||||
raise ValueError(f'{path}: inconsistent reference variants')
|
||||
tonic = generator.model.parse_pitch_name(data['tonic'])
|
||||
number = int(generator.model.NATURAL[tonic['letter']] + generator.model.alteration(tonic['alter']))
|
||||
harmony = tuple((F(4 * (h['bar'] - 1)), h['chord']) for h in data['harmony'])
|
||||
for _, chord in harmony:
|
||||
generator.harmony.parse_symbol(chord)
|
||||
return ReferencePhrase(data['id'], hashlib.sha256(raw).hexdigest(), plain, decorated, duration, number, harmony,
|
||||
tuple((f['id'], F(f['start']), F(f['end'])) for f in data['features']), tuple((F(w['start']), F(w['end'])) for w in data['run_windows']), data['acceptance'])
|
||||
|
||||
def load_library(root: pathlib.Path):
|
||||
return tuple(read(path) for path in sorted(root.glob('*.json')))
|
||||
|
||||
def identifying_features(notes, source):
|
||||
"""Select a characteristic leap followed by return, wherever it occurs."""
|
||||
notes = tuple(notes)
|
||||
candidates = []
|
||||
for i in range(len(notes) - 2):
|
||||
a, b, c = notes[i:i + 3]
|
||||
leap, reply = b.pitch.number() - a.pitch.number(), c.pitch.number() - b.pitch.number()
|
||||
if 3 <= abs(leap) <= 9 and leap * reply < 0 and c.start - a.start <= 4:
|
||||
group = notes[i:min(len(notes), i + 4)]
|
||||
rhythm = tuple((n.start - a.start, n.end - n.start) for n in group)
|
||||
intervals = tuple(y.pitch.number() - x.pitch.number() for x, y in zip(group, group[1:]))
|
||||
candidates.append(types.IdentifyingFeature(a.event_id + '_identity', tuple(n.event_id for n in group), rhythm, intervals, source))
|
||||
return tuple(candidates[:1])
|
||||
|
||||
def feature_error(feature, notes):
|
||||
by_id = {n.event_id: n for n in notes}
|
||||
actual = [by_id.get(identity) for identity in feature.events]
|
||||
if not all(actual):
|
||||
return 1.0
|
||||
first = actual[0].start
|
||||
rhythm = tuple((n.start - first, n.end - n.start) for n in actual)
|
||||
movement = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:]))
|
||||
timing = sum(a != b for a, b in zip(rhythm, feature.rhythm)) / max(1, len(rhythm))
|
||||
contour = sum(((a > 0) - (a < 0)) != ((b > 0) - (b < 0)) or abs(a - b) > 2 for a, b in zip(movement, feature.intervals)) / max(1, len(movement))
|
||||
return (timing + contour) / 2
|
||||
|
||||
def relationship(notes, reference):
|
||||
"""One joint relationship cost: timed movement, not absolute-pitch copying."""
|
||||
notes = tuple(notes)
|
||||
if len(notes) < 2 or len(reference.notes) < 2:
|
||||
return 0.0 if len(notes) == len(reference.notes) == 1 else 1.0
|
||||
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
|
||||
expected = reference.contour()
|
||||
count = min(len(actual), len(expected))
|
||||
contour = sum(0.75 * (((a > 0) - (a < 0)) != ((b > 0) - (b < 0))) + 0.25 * min(1, max(0, abs(a - b) - 2) / 7) for a, b in zip(actual, expected)) / max(1, count)
|
||||
start, ref_start = notes[0].start, reference.notes[0].start
|
||||
rhythm = sum(min(1, float(abs((n.start - start) - (r.start - ref_start))) / 2) for n, r in zip(notes, reference.notes)) / min(len(notes), len(reference.notes))
|
||||
evidence = max(0, min(3, len(reference.notes)) - len(notes)) / 3
|
||||
return max(evidence, (contour + rhythm) / 2)
|
||||
@@ -30,6 +30,9 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
|
||||
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()}')
|
||||
lines.append(f' Lead reference: {draft.reference}; selected features: ' + ', '.join(f.id for f in draft.features))
|
||||
for route in phrase.routes:
|
||||
lines.append(f' Complete route {route.id}: {route.notes[0].start}-{route.destination.start}; destination {route.destination.pitch.label()}; {route.relationship}; sources {route.sources}')
|
||||
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:
|
||||
@@ -92,5 +95,8 @@ def structure(solo):
|
||||
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})
|
||||
entry['lead_stages'] = {name: {'id': draft.id, 'attacks': len(draft.notes), 'notes': rational([dataclasses.asdict(n) for n in draft.notes])} for name, draft in (('initial', phrase.initial), ('refined', phrase.refined)) if draft}
|
||||
entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes])
|
||||
entry['score'] = phrase.score
|
||||
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])}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Instantiate complete authored routes; a failed path is never repaired note by note."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
|
||||
import generator.fretboard
|
||||
import generator.solo.policy
|
||||
import generator.solo.sequences
|
||||
import generator.solo.types as types
|
||||
|
||||
def advance(scale, previous, step):
|
||||
try:
|
||||
degree = types.scale_index(scale, previous)
|
||||
return tuple([types.scale_pitch(scale, degree + step)])
|
||||
except ValueError:
|
||||
# At a changed collection count actual intervening scale tones from the
|
||||
# incoming sound. No old degree number is transferred to the new scale.
|
||||
if not step:
|
||||
return ()
|
||||
nearby = sorted((types.scale_pitch(scale, i) for i in range(-28, 35)), key=lambda p: p.number())
|
||||
outgoing = [p for p in nearby if (p.number() - previous) * step > 0]
|
||||
if step < 0:
|
||||
outgoing.reverse()
|
||||
return tuple(outgoing[abs(step) - 1:abs(step)])
|
||||
|
||||
def candidates(notes, fixed, harmony, library, register, relationship='connect theme'):
|
||||
"""Every returned candidate includes its entry, complete rhythm and destination.
|
||||
|
||||
Callers validate the whole surrounding sounding context and guitar path.
|
||||
This function applies exact scale paths, fixed events and range constraints.
|
||||
"""
|
||||
if not 3 <= len(notes) <= 16:
|
||||
return ()
|
||||
first, destination = notes[0], notes[-1]
|
||||
span = harmony.at(first.start)
|
||||
if not span or not span.scale:
|
||||
return ()
|
||||
try:
|
||||
displacement = types.scale_index(span.scale, destination.pitch.number()) - types.scale_index(span.scale, first.pitch.number())
|
||||
displacements = tuple([displacement])
|
||||
except ValueError:
|
||||
# Enumerate possible degree routes when the destination changes collection;
|
||||
# actual endpoint equality below decides compatibility, not a pitch estimate.
|
||||
displacements = tuple(range(-7, 8))
|
||||
direction = (destination.pitch.number() > first.pitch.number()) - (destination.pitch.number() < first.pitch.number())
|
||||
paths = dict.fromkeys(path for d in displacements for path in library.retrieve(len(notes), d, direction))
|
||||
result = []
|
||||
low, high = generator.fretboard.single_register((register[0] - 7, register[1] + 5))
|
||||
allowed = {n.start: types.permitted(harmony.choices, n.start, n.end) for n in notes}
|
||||
for path in paths:
|
||||
realized = [first]
|
||||
for template, step in zip(notes[1:], path.steps):
|
||||
current = harmony.at(template.start)
|
||||
if not current or not current.scale:
|
||||
break
|
||||
options = advance(current.scale, realized[-1].pitch.number(), step)
|
||||
if not options:
|
||||
break
|
||||
pitch = options[0]
|
||||
if not low <= pitch.number() <= high or pitch.number() % 12 not in allowed[template.start]:
|
||||
break
|
||||
if (template.event_id in fixed or template is destination) and pitch.number() != template.pitch.number():
|
||||
break
|
||||
if not generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch.number())):
|
||||
break
|
||||
realized.append(dataclasses.replace(template, pitch=template.pitch if pitch.number() == template.pitch.number() else pitch, position=None))
|
||||
if len(realized) != len(notes):
|
||||
continue
|
||||
route_spans = tuple((max(first.start, c.span.start), min(destination.end, c.span.end), c.scale.label()) for c in harmony.choices if c.scale and c.span.start < destination.end and c.span.end > first.start)
|
||||
identity = generator.solo.policy.stable_rank('complete-route', (tuple(realized), path.sources))[:16]
|
||||
result.append(types.RouteCandidate(identity, tuple(realized), destination, route_spans, relationship, path.sources))
|
||||
return generator.solo.sequences.diverse(result,
|
||||
lambda r: (sum(abs(b.pitch.number() - a.pitch.number()) for a, b in zip(r.notes, r.notes[1:])), r.id),
|
||||
lambda r: generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(r.notes, r.notes[1:]))),
|
||||
generator.solo.policy.DEFAULT.join_alternatives)
|
||||
@@ -78,9 +78,9 @@ class Evaluation:
|
||||
for start, end, active, length, expected, occupied_goal, tolerance in self.windows:
|
||||
attacks = sum(start <= a < end and any(x <= a < y for x, y in active) for a, _, _ in sounded)
|
||||
occupied = sum(max(0, min(b, y) - max(a, x)) for a, b, _ in sounded for x, y in active)
|
||||
deviation = max(0, abs(attacks - expected) - tolerance)
|
||||
deviation = max(0, abs(attacks - expected) - max(tolerance, generator.solo.policy.DEFAULT.activity_deadband * length / self.duration))
|
||||
activity += length * min(1, deviation / max(1, expected))
|
||||
occupancy += length * abs(occupied / length - occupied_goal)
|
||||
occupancy += length * max(0, abs(occupied / length - occupied_goal) - generator.solo.policy.DEFAULT.occupancy_deadband)
|
||||
start, end = float(self.plan.start), float(self.until)
|
||||
durations = [max(0, min(b, end) - max(a, start)) for a, b, _ in sounded]
|
||||
register = sum(length * min(1, corridor(pitch, *self.plan.register) / 12) for (_, _, pitch), length in zip(sounded, durations)) / max(sum(durations), 0.001)
|
||||
|
||||
@@ -160,6 +160,23 @@ class DraftEdit:
|
||||
added: tuple[str, ...]
|
||||
source: str = ''
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class IdentifyingFeature:
|
||||
id: str
|
||||
events: tuple[str, ...]
|
||||
rhythm: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
|
||||
intervals: tuple[int, ...]
|
||||
source: str
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RouteCandidate:
|
||||
id: str
|
||||
notes: tuple[Note, ...]
|
||||
destination: Note
|
||||
spans: tuple[tuple[fractions.Fraction, fractions.Fraction, str], ...]
|
||||
relationship: str
|
||||
sources: tuple[str, ...]
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MelodicDraft:
|
||||
id: str
|
||||
@@ -173,6 +190,9 @@ class MelodicDraft:
|
||||
home: RealizedMotif | None
|
||||
relationship: str
|
||||
joins: tuple[fractions.Fraction, ...] = ()
|
||||
features: tuple[IdentifyingFeature, ...] = ()
|
||||
reference: str = ''
|
||||
reference_motif: Motif | RealizedMotif | None = None
|
||||
def rests(self):
|
||||
cursor, result = self.start, []
|
||||
for note in self.notes:
|
||||
@@ -244,6 +264,9 @@ class Phrase:
|
||||
anchors: tuple[Anchor, ...] = ()
|
||||
draft: MelodicDraft | None = None
|
||||
edits: tuple[DraftEdit, ...] = ()
|
||||
initial: MelodicDraft | None = None
|
||||
refined: MelodicDraft | None = None
|
||||
routes: tuple[RouteCandidate, ...] = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Solo:
|
||||
|
||||
Reference in New Issue
Block a user