Soloing
ci / checks (push) Successful in 1h53m40s

This commit is contained in:
khodowany
2026-10-01 11:23:47 -05:00
parent 75655786e9
commit 5414caf30a
14 changed files with 1085 additions and 259 deletions
+40 -16
View File
@@ -94,7 +94,7 @@ def capture(root, stage):
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)}
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)}
print(path.stem, solo.part, json.dumps(song['solos'][solo.part]['metrics']), flush=True)
result['songs'][path.stem] = song
destination.mkdir(parents=True)
@@ -152,27 +152,33 @@ def mix(documents, tempo, root, cache):
return output
def render(root):
snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in STAGES}
stages = ('before', 'after') if (root / 'before' / 'snapshot.json').exists() else STAGES
snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in stages}
cache, clips, manifest = {}, [], {'sample_rate': generator.playback.SAMPLE_RATE, 'stages': {}, 'clips': []}
for stage, snapshot in snapshots.items():
manifest['stages'][stage] = {k: v for k, v in snapshot.items() if k != 'songs'}
for song_name, song in snapshot['songs'].items():
baseline = snapshots['baseline']['songs'][song_name]
baseline = snapshots[stages[0]]['songs'][song_name]
if song['source_sha256'] != baseline['source_sha256']:
raise ValueError(f'{song_name}: source changed between audition stages')
for part, solo in song['solos'].items():
other = 'electric' if part == 'acoustic' else 'acoustic'
first, count = solo['first'], min(8, solo['count'])
foreground = excerpt(song['parts'][part], first, count)
prefix = root / stage / f'{song_name}_{part}'
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), foreground)
prefix.with_suffix('.txt').write_text(solo['report'])
for responsive in [False, True] if stage == 'D' else [False]:
backing = excerpt((song if responsive else baseline)['parts'][other], first, count)
path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav')
samples = mix((foreground, backing), fractions.Fraction(solo['tempo']), root, cache)
clips.append((path, samples))
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
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']))]
for label, offset, count in windows:
first = solo['first'] + offset
foreground = excerpt(song['parts'][part], first, count)
prefix = root / stage / (f'{song_name}_{part}' + ('_' + label if label else ''))
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), foreground)
prefix.with_suffix('.txt').write_text(solo['report'])
if 'structure' in solo:
prefix.with_suffix('.json').write_text(json.dumps(solo['structure'], indent=2) + '\n')
for responsive in [False, True] if stage == stages[-1] else [False]:
backing = excerpt((song if responsive else baseline)['parts'][other], first, count)
vocal = excerpt(baseline['parts']['vocal'], first, count)
path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav')
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
clips.append((path, samples))
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'window': label, 'first': offset, 'count': count, 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
# 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
@@ -180,7 +186,7 @@ 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_index(root, snapshots)
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_index(root, snapshots):
@@ -197,11 +203,29 @@ def write_index(root, snapshots):
body.append(f'</table><p>D with responsive backing: <audio controls preload="none" src="D/{name}_{part}_responsive.wav"></audio></p>')
(root / 'index.html').write_text(''.join(body) + '</html>\n')
def write_passage_index(root, snapshots):
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Connected solo passages</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:.75rem;border-bottom:1px solid #ccd1c9}audio{width:260px}small{display:block;margin-top:.5rem}</style><h1>Connected solo passages</h1><p>Before and after use the same source songs, scale-selection settings, activity targets, tempo and root seed inputs. Fixed comparisons retain the original backing. Responsive comparisons include accompaniment recomposed around the new foreground. The returning vocal pickup and one shared mix gain are preserved.</p><p>Listen for a convincing settlement on the current chord, an idea developing across several bars, intermediate arrivals that continue, and a prepared ending. Metrics alone do not establish musical improvement. <a href="manifest.json">Fingerprints and diagnostics</a>.</p>']
for name, song in snapshots['after']['songs'].items():
for part in song['solos']:
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2>')
for stage in ('before', 'after'):
metric = snapshots[stage]['songs'][name]['solos'][part]['metrics']
body.append(f"<p>{stage.title()}: {metric['attacks_per_four_beats']:.2f} attacks / four beats; {metric['sounding_occupancy']:.1%} occupancy; longest unplanned gap {metric['longest_unplanned_gap_beats']:g} beats.</p>")
body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>After, responsive backing</th></tr>')
for window in ('full', 'opening8', 'closing4'):
body.append(f'<tr><th>{window}</th>')
for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')):
prefix = stage + '/' + name + '_' + part + '_' + window
body.append(f'<td><audio controls preload="none" src="{prefix}_{mix_name}.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a>' + (f' · <a href="{prefix}.json">Passage data</a>' if stage == 'after' else '') + '</small></td>')
body.append('</tr>')
body.append('</table>')
(root / 'index.html').write_text(''.join(body) + '</html>\n')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_auditions'))
action = parser.add_mutually_exclusive_group(required=True)
action.add_argument('--capture', choices=STAGES)
action.add_argument('--capture', choices=(*STAGES, 'before', 'after'))
action.add_argument('--render', action='store_true')
args = parser.parse_args()
capture(args.output, args.capture) if args.capture else render(args.output)
+41 -6
View File
@@ -8,6 +8,7 @@ import generator.harmony
import generator.model
import generator.scales.selection
import generator.scales.timeline
import generator.solo.harmonic
import generator.solo.analysis
import generator.solo.planning
import generator.solo.policy
@@ -31,17 +32,37 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
context = generator.solo.realization.SearchContext()
def realize(plan, previous):
return generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context)
original = plan
for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1):
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)
phrases = choose_phrases(plans, realize)
context.diagnostics['policy'] = generator.solo.policy.describe()['fingerprint']
context.diagnostics['musical_identity'] = generator.solo.analysis.identity(analysis, measures, part)
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end, context.diagnostics)
passage_ids = tuple(dict.fromkeys(p.passage_id for p in plans if p.passage_id))
passages = tuple(generator.solo.types.MelodicPassagePlan(identifier, min(p.start for p in plans if p.passage_id == identifier), max(p.end for p in plans if p.passage_id == identifier), tuple(p.id for p in plans if p.passage_id == identifier), next(p.seed for p in plans if p.passage_id == identifier)) for identifier in passage_ids)
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end, context.diagnostics, passages)
validate(result)
return result
def phrase_notes(phrase):
return generator.solo.types.sounding_notes(n for d in phrase.decisions for n in d.notes)
notes = tuple(n for d in phrase.decisions for n in d.notes)
if notes and notes[0].continuation:
notes = (dataclasses.replace(notes[0], continuation=False), *notes[1:])
return generator.solo.types.sounding_notes(notes)
def transition_cost(first, second):
before, after = phrase_notes(first), phrase_notes(second)
@@ -70,20 +91,23 @@ def choose_phrases(plans, realize):
ranked.append((option.score + incoming, option))
continue
try:
following = realize(plans[index + 1], last)
following = realize(plans[index + 1], option.state or last)
except generator.solo.realization.SearchExhausted as error:
failure = error
continue
length = float(option.plan.end - option.plan.start)
next_length = float(plans[index + 1].end - plans[index + 1].start)
cost = min((option.score * length + future.score * next_length) / (length + next_length) + transition_cost(option, future) for future in following)
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))
if not ranked:
raise failure or generator.solo.realization.SearchExhausted(f'{plan.id}: no feasible phrase transition')
_, chosen = min(ranked, key=lambda item: (item[0], tuple((n.pitch, n.start, n.end, n.position) for n in phrase_notes(item[1]))))
result.append(chosen)
notes = phrase_notes(chosen)
if notes:
if chosen.state:
previous = chosen.state
elif notes:
previous = notes[-1]
return tuple(result)
@@ -101,3 +125,14 @@ def validate(solo):
notes = phrase_notes(phrase)
if not generator.solo.realization.target_ok(notes, phrase.plan.target):
raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized')
harmony = generator.solo.harmonic.Harmony(solo.choices)
contracts = tuple(dict.fromkeys(t for phrase in solo.phrases if phrase.state for t in phrase.state.treatments))
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True)
if not checked.valid:
raise ValueError(f'{solo.part} solo: {checked.error}')
for phrase in solo.phrases:
if phrase.state:
complete = phrase.plan.end == (phrase.plan.passage_end or phrase.plan.end)
valid, error = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=complete, closure=phrase.plan.closure)
if not valid:
raise ValueError(error)
+162
View File
@@ -0,0 +1,162 @@
"""Chord function on exact sounding intervals, separate from scale permission."""
from __future__ import annotations
import dataclasses
import fractions
import functools
import generator.harmony
import generator.model
import generator.solo.policy
import generator.solo.types
@dataclasses.dataclass(frozen=True)
class Roles:
basic: frozenset[int]
specified: frozenset[int]
degrees: tuple[tuple[int, tuple[int, ...]], ...]
@functools.lru_cache(maxsize=512)
def degree_roles(kind, root, degrees):
basic, specified, labels = set(), set(), {}
for degree, alteration in degrees:
pitch = int(root + generator.harmony.degree_semitones(degree, alteration)) % 12
structural = degree in (1, 3, 5, 6, 7) or degree in (2, 4) and kind in ('suspended-second', 'suspended-fourth')
(basic if structural else specified).add(pitch)
labels.setdefault(pitch, []).append(degree)
return Roles(frozenset(basic), frozenset(specified - basic), tuple((p, tuple(v)) for p, v in sorted(labels.items())))
def roles(chord):
if not chord or chord['kind'] == 'none':
return Roles(frozenset(), frozenset(), ())
root = chord['root']
number = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
return degree_roles(chord['kind'], number, tuple(sorted(generator.harmony.degrees(chord).items())))
@dataclasses.dataclass(frozen=True)
class Span:
start: fractions.Fraction
end: fractions.Fraction
roles: Roles
scale: object
chord: str
@dataclasses.dataclass(frozen=True)
class Result:
valid: bool
exposure: float
pending: tuple[generator.solo.types.HarmonicTreatment, ...] = ()
error: str = ''
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)
def at(self, time):
return next((s for s in self.spans if s.start <= time < s.end), None)
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)
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()
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'
def step(self, first, second, time):
span = self.at(time)
if not span or not span.scale:
return False
try:
return abs(generator.solo.types.scale_index(span.scale, first) - generator.solo.types.scale_index(span.scale, second)) == 1
except ValueError:
return False
def connected_motion(self, notes, index):
if not 0 < index < len(notes) - 1:
return ''
before, note, after = notes[index - 1:index + 2]
if before.end != note.start or note.end != after.start:
return ''
a, b, c = (n.pitch.number() for n in (before, note, after))
if self.step(a, b, note.start) and self.step(b, c, note.start):
return 'neighbor' if a == c else 'passing' if (b - a) * (c - b) > 0 else ''
return ''
def evaluate(self, notes, treatments=(), *, until=None, complete=False):
notes = generator.solo.types.sounding_notes(notes)
until = until if until is not None else max((n.end for n in notes), default=fractions.Fraction())
pending, valid_contracts = [], []
for treatment in treatments:
if treatment.start >= until:
continue
if treatment.kind == 'color':
spans = [s for s in self.spans if s.start < treatment.end and s.end > treatment.start]
if not spans or any(treatment.pitch % 12 not in s.roles.basic | s.roles.specified for s in spans):
return Result(False, 0, error=f'{treatment.id}: color is not specified by the overlapped harmony')
valid_contracts.append(treatment)
continue
if treatment.kind != 'suspension' or treatment.resolution is None or treatment.deadline is None:
return Result(False, 0, error=f'{treatment.id}: incomplete harmonic treatment')
held = next((n for n in notes if n.pitch.number() == treatment.pitch and n.start <= treatment.start - treatment.preparation and n.end > treatment.start), None)
if held is None:
return Result(False, 0, error=f'{treatment.id}: suspension lacks sounding preparation')
if treatment.pitch % 12 not in self.members(treatment.start - treatment.preparation, treatment.start):
return Result(False, 0, error=f'{treatment.id}: preparation is not a chord member')
resolution = next((n for n in notes if n.start == held.end and n.pitch.number() == treatment.resolution and n.start <= treatment.deadline), None)
if resolution:
span = self.at(resolution.start)
if not span or treatment.resolution % 12 not in span.roles.basic or not self.step(treatment.pitch, treatment.resolution, resolution.start):
return Result(False, 0, error=f'{treatment.id}: resolution is not an adjacent chord member')
elif complete or until > treatment.deadline or any(n.start >= held.end for n in notes if n is not held):
return Result(False, 0, error=f'{treatment.id}: unresolved suspension at deadline {treatment.deadline}')
else:
pending.append(treatment)
valid_contracts.append(treatment)
exposed, sounding = 0.0, 0.0
for index, note in enumerate(notes):
number = note.pitch.number()
duration = note.end - note.start
sounding += float(duration)
covered = note.start
for span, start, end in self.segments(note):
if start > covered or not span.scale or number % 12 not in span.scale.pitches:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
covered = end
function = self.function(number, span)
if function == 'basic' or note.role == 'quote':
continue
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None)
if duration >= generator.solo.policy.DEFAULT.long_note and contract is None:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment')
connected = duration < generator.solo.policy.DEFAULT.stopping_note and self.connected_motion(notes, index)
cost = 0 if contract or connected else 1 if duration >= generator.solo.policy.DEFAULT.stopping_note else 0.25
exposed += float(end - start) * cost
if covered < note.end:
return Result(False, 0, error=f'{note.pitch.label()} at {covered}: missing harmonic coverage')
return Result(True, exposed / max(sounding, 0.001), tuple(pending))
def describe(self, notes, treatments=()):
notes = generator.solo.types.sounding_notes(notes)
result = []
for index, note in enumerate(notes):
for span, start, end in self.segments(note):
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'protected': note.role == 'quote', 'treatments': [t.id for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]})
return result
def anchor_note(anchor, notes, *, until=None):
for note in notes:
if note.pitch.number() != anchor.pitch.number():
continue
arrival = note.start if anchor.onset_required else max(note.start, anchor.earliest)
needed = max(arrival + anchor.duration, anchor.hold_until or arrival)
if anchor.earliest <= arrival <= anchor.latest and note.start <= arrival < note.end and note.end >= min(needed, until if until is not None else needed):
return note
return None
def anchors_valid(anchors, notes, until, *, complete=False, closure='open'):
for anchor in anchors:
if anchor.earliest >= until and not complete:
continue
note = anchor_note(anchor, notes, until=None if complete else until)
if note is None and (complete or until > anchor.latest):
return False, f'{anchor.id}: required {anchor.role} arrival {anchor.earliest}-{anchor.latest} on {anchor.pitch.label()} not completed'
if note and anchor.role == 'terminal' and closure == 'closed' and any(n.start > note.start for n in notes):
return False, f'{anchor.id}: terminal anchor touched then abandoned'
return True, ''
+56
View File
@@ -0,0 +1,56 @@
"""Realized gesture identities and their explicit development relationships."""
from __future__ import annotations
import dataclasses
import fractions
import generator.solo.harmonic
import generator.solo.scoring
import generator.solo.types
def reference(plan, gesture, realized):
if not realized or gesture is None or gesture.relationship == 'statement':
return plan.motif
# Repetition returns to the introduced identity; extension/answer develops
# the most recently heard related gesture, never an unrelated catalog winner.
return realized[0] if gesture.relationship == 'repeat_with_harmonic_adaptation' else realized[-1]
def local_notes(notes, start, end):
return tuple(dataclasses.replace(n, start=n.start - start, end=min(n.end, end) - start, position=None, continuation=False) for n in notes if start <= n.start < end)
def complete(gesture, reference, notes, harmony):
actual = local_notes(notes, gesture.start, gesture.end)
sounded = any(n.start < gesture.end and n.end > gesture.start for n in notes)
active = any(s.scale and s.start < gesture.end and s.end > gesture.start for s in harmony.spans)
if not active:
return True, 0.0, None
if gesture.relationship == 'close':
return sounded, 0.0, None
required = min(len(reference.notes), 2)
if len(actual) < required:
return False, 1.0, None
fidelity = generator.solo.scoring.motif_fidelity(actual, reference, gesture.treatment)
if gesture.treatment == 'rhythmic_reference':
expected = tuple((n.start, min(n.end, gesture.end - gesture.start)) for n in reference.notes if n.start < gesture.end - gesture.start)
if tuple((n.start, n.end) for n in actual) != expected:
return False, 1.0, None
elif gesture.relationship != 'statement' and fidelity >= 0.75:
return False, fidelity, None
degrees = []
for n in actual:
span = harmony.at(gesture.start + n.start)
degrees.append(dict(span.roles.degrees).get(n.pitch.number() % 12, ()) if span else ())
result = generator.solo.types.RealizedMotif(gesture.id, reference.id, actual, gesture.end - gesture.start, gesture.relationship, gesture.treatment, tuple(degrees), gesture.start)
return True, fidelity, result
def interval_steps(reference, harmony, source_start=None):
result = []
source_start = getattr(reference, 'source_start', source_start or fractions.Fraction())
for first, second in zip(reference.notes, reference.notes[1:]):
span = harmony.at((source_start or fractions.Fraction()) + first.start)
try:
step = generator.solo.types.scale_index(span.scale, second.pitch.number()) - generator.solo.types.scale_index(span.scale, first.pitch.number()) if span and span.scale else None
except ValueError:
step = None
result.append((step, second.pitch.number() - first.pitch.number()))
return tuple(result)
+118 -48
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
import dataclasses
import fractions
import generator.harmony
import generator.model
import generator.scales.selection
import generator.solo.harmonic
import generator.solo.analysis
import generator.solo.policy
import generator.solo.sequences
@@ -91,67 +93,135 @@ def activity_passages(start, end, release, activity, shape=0):
arrival = i == len(spans) - 1
rate = arrival_rate if arrival else fractions.Fraction(str(min(2, float(desired) * weights[min(i, 2)] / max(denominator, 0.001)))).limit_denominator(1000)
occupancy = generator.solo.policy.DEFAULT.arrival_occupancy if arrival else generator.solo.policy.DEFAULT.active_occupancy
result.append(generator.solo.types.Passage(a, b, 'sustained_arrival' if arrival else 'active', rate, fractions.Fraction(str(occupancy))))
result.append(generator.solo.types.ActivitySpan(a, b, 'sustained_arrival' if arrival else 'active', rate, fractions.Fraction(str(occupancy))))
if release:
result.append(generator.solo.types.Passage(music_end, end, 'space', fractions.Fraction(), fractions.Fraction()))
result.append(generator.solo.types.ActivitySpan(music_end, end, 'space', fractions.Fraction(), fractions.Fraction()))
return tuple(result)
def decision(seed, name):
return int(generator.solo.policy.stable_rank(seed, name)[:8], 16)
def same_harmony(left, right):
return bool(left and right and left.span.chord and right.span.chord and generator.harmony.symbol(left.span.chord) == generator.harmony.symbol(right.span.chord))
def resolutions(choices):
distinct = []
for choice in choices:
if not distinct or not same_harmony(distinct[-1], choice):
distinct.append(choice)
return tuple(right for left, right in zip(distinct, distinct[1:]) if left.span.chord and right.span.chord and generator.scales.selection.dominant(left.span.chord) and (generator.scales.selection.root_number(left.span.chord) - generator.scales.selection.root_number(right.span.chord)) % 12 == 7)
def anchor_backbones(identifier, start, end, release, register, choices, bar, seed):
active = [c for c in choices if c.scale and c.span.start < end - release and c.span.end > start]
if not active:
return tuple([()])
finish = min(end - release, active[-1].span.end)
terminal_duration = min(fractions.Fraction(2), (finish - start) / 2)
terminal_start = max(start, active[-1].span.start, finish - terminal_duration)
terminal_duration = finish - terminal_start
opening = max(start, active[0].span.start)
moments = [(opening, 'opening')]
meaningful = list(resolutions(active)) + [b for a, b in zip(active, active[1:]) if a.scale.pitches != b.scale.pitches]
for choice in sorted(meaningful, key=lambda c: c.span.start):
time = choice.span.start
if time >= moments[-1][0] + bar * fractions.Fraction(3, 2) and time + bar < terminal_start and len(moments) < 4:
moments.append((time, 'intermediate'))
if terminal_start <= opening + fractions.Fraction(1, 2):
moments = []
moments.append((terminal_start, 'terminal'))
states = [(0.0, ())]
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)
eligible = (chord_roles.basic | chord_roles.specified) & choice.scale.pitches
duration = terminal_duration if role == 'terminal' else fractions.Fraction(1, 2)
latest = time if role == 'terminal' else min(time + fractions.Fraction(3, 2), choice.span.end - duration, terminal_start - duration)
pitches = [generator.solo.types.scale_pitch(choice.scale, degree) for degree in range(-21, 28)]
pitches = [p for p in pitches if register[0] <= p.number() <= register[1] and p.number() % 12 in eligible]
desired = register[0] + (5 if index == 0 else 8 if role == 'terminal' else 11)
following = []
for cost, anchors 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
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)
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)
def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choices, end: fractions.Fraction, part: str, sequences: generator.solo.sequences.Library) -> tuple[generator.solo.types.PhrasePlan, ...]:
boundaries = [fractions.Fraction()]
for measure in measures:
boundaries.append(min(end, boundaries[-1] + generator.model.fraction(measure['duration'])))
head, contrast, invented = choose_motifs(analysis, sequences, boundaries[1])
seed = generator.solo.policy.stable_rank(generator.solo.analysis.identity(analysis, measures, part), generator.solo.policy.describe()['fingerprint'])
corridor = (55, 74) if part == 'acoustic' else (59, 79)
invented = new_motif(sequences, boundaries[1], choices=choices, register=corridor, seed=seed)
alternate = new_motif(sequences, boundaries[1], choices=choices, register=corridor, seed=seed, variant=1)
result, index = [], 0
head, contrast, _ = choose_motifs(analysis, sequences, boundaries[1])
seed = generator.solo.policy.stable_rank(generator.solo.analysis.identity(analysis, measures, part), generator.solo.policy.SEED_NAMESPACE)
activity = fractions.Fraction(str(generator.solo.policy.DEFAULT.acoustic_activity if part == 'acoustic' else generator.solo.policy.DEFAULT.electric_activity))
result, index, passage_index, inventions = [], 0, 0, {}
cadences = resolutions(choices)
while index < len(measures) and boundaries[index] < end:
start = boundaries[index]
possible = list(range(min(index + 2, len(measures)), min(index + 4, len(measures)) + 1))
stops = sorted({min(index + size, len(measures)) for size in (4, 6, 8)})
def boundary_rank(stop):
time = boundaries[stop]
before = next((c for c in reversed(choices) if c.span.start < time), None)
after = next((c for c in choices if c.span.start >= time and c.span.chord), None)
resolution = before and after and before.span.chord and generator.scales.selection.dominant(before.span.chord) and (generator.scales.selection.root_number(before.span.chord) - generator.scales.selection.root_number(after.span.chord)) % 12 == 7
source_index = min(stop + int(len(analysis.boundaries) > len(measures) + 1), len(analysis.boundaries) - 1)
source_time = analysis.boundaries[source_index]
preceding = [n for n in analysis.melody if n.start < source_time]
breathing = bool(preceding) and preceding[-1].end <= source_time and (preceding[-1].end < source_time or preceding[-1].end - preceding[-1].start >= 2)
return not resolution, not breathing, abs(stop - index - 3), stop
def destination_rank(stop):
finish = boundaries[stop]
last = next((c for c in reversed(choices) if c.scale and c.span.start < finish and c.span.end > start), None)
resolved = bool(last and any(start < c.span.start < finish and same_harmony(c, last) for c in cadences))
dominant = bool(last and generator.scales.selection.dominant(last.span.chord))
return (0 if resolved else 2 if dominant else 1), abs(stop - index - 4), finish
stop = min(possible, key=boundary_rank)
stop = min(stops, key=destination_rank)
finish = boundaries[stop]
final = finish == end
phase = (start + finish) / (2 * end)
phrase_seed = generator.solo.policy.stable_rank(seed, (str(start), str(finish)))
choice = int(phrase_seed[:8], 16)
relationship = 'statement' if not result else 'repeat' if len(result) == 1 else 'answer' if len(result) % 2 else 'variation'
if part == 'acoustic':
motif = head if len(result) < 2 else (head, contrast, invented)[choice % 3]
else:
motif = invented if len(result) < 2 else (invented, alternate, head)[choice % 3]
treatment = 'opening_reference' if not result and motif.origin == 'melody' else 'invented' if motif.origin != 'melody' else 'answer' if relationship == 'answer' else ('rhythmic_reference', 'contour_reference')[choice % 2]
if finish <= start:
break
passage_index += 1
passage_id = f'{part}_passage_{passage_index}'
passage_seed = generator.solo.policy.stable_rank(seed, (str(start), str(finish)))
low, high = (55, 74) if part == 'acoustic' else (59, 79)
# Reserve the upper register for a prepared peak, not every active passage.
phase = (start + finish) / (2 * end)
register = (low + (5 if fractions.Fraction(1, 2) <= phase < fractions.Fraction(3, 4) else 0), high if phase >= fractions.Fraction(1, 2) else high - 4)
activity = fractions.Fraction(str(generator.solo.policy.DEFAULT.acoustic_activity if part == 'acoustic' else generator.solo.policy.DEFAULT.electric_activity))
ending = ('connected', 'sustain', 'breath')[choice % 3]
release = fractions.Fraction(1, 2) if choice % 2 else fractions.Fraction(1)
release = min(release, (finish - start) / 4) if ending == 'breath' else fractions.Fraction()
if treatment == 'rhythmic_reference' and motif.duration > finish - start - release:
treatment = 'contour_reference'
closure = 'closed' if final or relationship == 'answer' else 'open'
passages = activity_passages(start, finish, release, activity, choice % 3)
sounding = [p for p in passages if p.intent != 'space']
peak = sounding[min(1, len(sounding) - 1)]
ascending = choice % 2 == 0
landmarks = (
generator.solo.types.Landmark(start, min(start + 1, peak.start), register[0] + (3 if ascending else 8), register[0] + (7 if ascending else 12), 0, 'entrance'),
generator.solo.types.Landmark(peak.start, peak.end, register[1] - 6 if ascending else register[0] + 2, register[1] - 2 if ascending else register[0] + 6, 1 if ascending else -1, 'peak' if ascending else 'low point'),
generator.solo.types.Landmark(sounding[-1].start, sounding[-1].end, register[0] + (4 if ascending else 8), register[0] + (9 if ascending else 13), -1 if ascending else 1, 'arrival'),
)
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', start, finish, motif, relationship, treatment, destination(choices, start, finish, final, release, closure), register, activity, release, passages, landmarks, ending, closure, phrase_seed))
bar = boundaries[index + 1] - start
release = fractions.Fraction(1, 2) if decision(passage_seed, 'breathing') % 3 == 0 else fractions.Fraction()
release = min(release, (finish - start) / 8)
envelope = activity_passages(start, finish, release, activity, decision(passage_seed, 'activity') % 3)
anchors = anchor_backbones(passage_id, start, finish, release, register, choices, bar, passage_seed)
# Prefer four-bar phrasing; extend when a sustained harmony crosses that seam.
middle = min(index + 4, stop)
before = next((c for c in reversed(choices) if c.span.start < boundaries[middle]), None)
after = next((c for c in choices if c.span.start <= boundaries[middle] < c.span.end), None)
extended = stop - index > 4 and same_harmony(before, after)
phrase_stops = [stop] if extended or middle == stop else [middle, stop]
local = tuple(c for c in choices if c.span.start < boundaries[phrase_stops[0]] and c.span.end > start)
slot = decision(passage_seed, 'motif') % 3
if part == 'acoustic':
slot = 0 if passage_index <= 2 else slot
if slot == 2 and 0 not in inventions:
inventions[0] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed)
motif = head if slot == 0 else contrast if slot == 1 else inventions[0]
else:
slot = 0 if passage_index == 1 else slot
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))
phrase_start = phrase_end
index = stop
return tuple(result)
+10 -2
View File
@@ -11,18 +11,26 @@ 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))
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))
beam_width: int = 64
extensions: int = 32
retrieval: int = 32
alternatives: int = 4
long_note: float = 2.0
stopping_note: float = 1.0
suspension_preparation: float = 0.5
suspension_resolution: float = 1.0
replan_attempts: int = 2
# Preserve the former default root seeds while decoupling them from policy edits.
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
DEFAULT = Policy()
def describe():
values = dataclasses.asdict(DEFAULT)
identity = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()
return {'fingerprint': identity, 'parameters': values}
return {'fingerprint': identity, 'seed_namespace': SEED_NAMESPACE, 'parameters': values}
def stable_rank(seed, value):
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
+306 -174
View File
@@ -3,12 +3,13 @@ from __future__ import annotations
import dataclasses
import fractions
import functools
import collections
import generator.fretboard
import generator.model
import generator.rhythm
import generator.scales.selection
import generator.solo.harmonic
import generator.solo.motifs
import generator.solo.placement
import generator.solo.planning
import generator.solo.scoring
@@ -28,6 +29,7 @@ class Cell:
end: fractions.Fraction
patterns: tuple[tuple[str, tuple[tuple[fractions.Fraction, fractions.Fraction], ...]], ...]
motif: generator.solo.types.Motif | None = None
gesture: generator.solo.types.Gesture | None = None
@dataclasses.dataclass(frozen=True)
class Candidate:
@@ -36,6 +38,7 @@ class Candidate:
treatment: str
rank: tuple
fidelity: float = 0
treatments: tuple[generator.solo.types.HarmonicTreatment, ...] = ()
def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fractions.Fraction, activity: fractions.Fraction, offset=fractions.Fraction()):
result = {}
@@ -66,7 +69,7 @@ def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fract
selected = generator.solo.sequences.diverse(result.items(), rank, family, 12)
return tuple((identifier, pattern) for pattern, identifier in selected)
def cells(plan, measures, rhythms):
def cells(plan, measures, rhythms, choices=()):
result, boundaries = [], [fractions.Fraction()]
for measure in measures:
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
@@ -82,7 +85,9 @@ def cells(plan, measures, rhythms):
while start < music_end:
index = next(i for i in range(len(measures)) if boundaries[i] <= start < boundaries[i + 1])
passage = next((p for p in plan.passages if p.start <= start < p.end), None)
end = min(music_end, boundaries[index + 1], passage.end if passage else music_end)
gesture = next((g for g in plan.gestures if g.start <= start < g.end), None)
chord_end = next((c.span.end for c in choices if c.span.start <= start < c.span.end), music_end)
end = min(music_end, boundaries[index + 1], passage.end if passage else music_end, gesture.end if gesture else music_end, chord_end)
activity = passage.activity if passage else plan.activity
patterns = rhythm_patterns(rhythms, measures[index]['time_signature'], end - start, activity, start - boundaries[index])
patterns += (('hold', ((fractions.Fraction(), end - start),)), ('space', ()))
@@ -90,7 +95,7 @@ def cells(plan, measures, rhythms):
onset = max(start, plan.target.earliest) - start
patterns = (('arrival_and_release', ((onset, end - start),)), *patterns)
motif = plan.motif if not result else None
result.append(Cell(start, end, patterns, motif))
result.append(Cell(start, end, patterns, motif, gesture))
start = end
if plan.release:
result.append(Cell(music_end, plan.end, (('planned_release', ()),)))
@@ -111,27 +116,6 @@ def options(choices, start, end, register):
def target_ok(notes, target):
return target is None or any(target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches for n in notes)
def thematic_paths(cell, spans, choices, plan):
states = [(0.0, ())]
for index, (start, end) in enumerate(spans):
following = []
available = options(choices, start, end, plan.register)
for _, path in states:
for pitch in available:
reference = cell.motif.notes[min(len(cell.motif.notes) - 1, index * len(cell.motif.notes) // max(1, len(spans)))]
note = generator.solo.types.Note(start, end, pitch, reference.sources, 'motif')
notes = (*path, note)
cost = generator.solo.scoring.motif_fidelity(notes, cell.motif, plan.treatment)
direction = plan.landmarks[1].direction if len(plan.landmarks) > 1 else 0
motion = notes[-1].pitch.number() - notes[0].pitch.number()
cost += 0.1 * (direction and motion * direction < 0)
cost += sum(generator.solo.scoring.corridor(n.pitch.number(), *plan.register) for n in notes) / (12 * len(notes))
following.append((cost, notes))
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], tuple(n.pitch for n in item[1])), lambda item: (item[1][-1].pitch.number(), generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(item[1], item[1][1:])))), BEAM_WIDTH))
if not states:
break
return [(generator.solo.scoring.motif_fidelity(notes, cell.motif, plan.treatment), notes) for _, notes in states]
def adapt_path(pitches, spans, choices, register):
"""Connect actual pitches across collections without carrying a stale degree index."""
states = [(0, ())]
@@ -145,112 +129,237 @@ def adapt_path(pitches, spans, choices, register):
states = sorted(following, key=lambda item: (item[0], tuple(n.pitch for n in item[1])))[:8]
return [notes for _, notes in states]
def sequence_paths(spans, choices, plan, sequences):
if not spans:
return [((), 'space')]
first_scale = next((c.scale for c in choices if c.span.start <= spans[0][0] < c.span.end), None)
if first_scale is None:
return []
allowed = tuple(generator.solo.types.permitted(choices, start, end) for start, end in spans)
if not all(allowed):
return []
available = options(choices, *spans[0], plan.register)
if len(spans) == 1:
return [((generator.solo.types.Note(*spans[0], pitch, role='path'),), 'single') for pitch in available]
landmark = next((p for p in plan.landmarks if p.latest >= spans[0][0]), None)
center = (landmark.low + landmark.high) // 2 if landmark else sum(plan.register) // 2
direction = landmark.direction if landmark else 0
starts = sorted(available, key=lambda p: (generator.solo.scoring.corridor(p.number(), *plan.register), abs(p.number() - center), p))[:8]
paths, identities = [], set()
for first in starts:
degree = next(i for i in range(-21, 28) if generator.solo.types.scale_pitch(first_scale, i) == first)
destination = center
if plan.target and spans[-1][0] >= plan.target.earliest:
compatible = [p for p in options(choices, *spans[-1], plan.register) if p.number() % 12 in plan.target.pitches]
if compatible:
destination = min(compatible, key=lambda p: (abs(p.number() - center), p)).number()
retrieved = sequences.retrieve(len(spans), round((destination - first.number()) / 2), direction)
routes = [(p.offsets(), 'sequences ' + ' + '.join(p.sources)) for p in retrieved]
routes.append(((0,) * len(spans), 'repeated_note'))
for offsets, identifier in routes:
pitches = tuple(generator.solo.types.scale_pitch(first_scale, degree + step) for step in offsets)
if not all(max(41, plan.register[0] - 7) <= p.number() <= min(84, plan.register[1] + 5) for p in pitches):
continue
if all(p.number() % 12 in classes for p, classes in zip(pitches, allowed)):
realized = [tuple(generator.solo.types.Note(start, end, pitch, role='path') for (start, end), pitch in zip(spans, pitches))]
elif any(classes != allowed[0] for classes in allowed):
realized = adapt_path(pitches, spans, choices, plan.register)
identifier = 'harmonic_adaptation of ' + identifier
else:
continue
for notes in realized:
identity = tuple(n.pitch for n in notes)
if identity not in identities:
identities.add(identity)
paths.append((notes, identifier))
return paths
def candidates(cell, plan, choices, sequences):
def anchor_treatments(anchors, harmony):
result = []
target = plan.target if plan.target and cell.start <= plan.target.latest < cell.end else None
for anchor in anchors:
span = harmony.at(anchor.earliest)
if span and anchor.pitch.number() % 12 in span.roles.specified:
result.append(generator.solo.types.HarmonicTreatment(anchor.id + '_color', 'color', anchor.pitch.number(), anchor.earliest, anchor.hold_until or anchor.latest + anchor.duration))
return tuple(result)
def rhythm_variants(cell, plan, state, anchors, reference):
patterns = cell.patterns
gesture = cell.gesture
if cell.motif and plan.treatment in ('quote', 'exact_quote'):
return [(name, tuple((cell.start + a, cell.start + b) for a, b in pattern), {}, False) for name, pattern in patterns]
if gesture and gesture.treatment == 'rhythmic_reference' and state.motifs:
# The identity is the rhythm actually heard, including its rests. Cropping
# at a search boundary does not create a new rhythmic attack.
pattern = tuple((max(cell.start, gesture.start + n.start) - cell.start, min(cell.end, gesture.start + n.end) - cell.start) for n in reference.notes if gesture.start + n.start < cell.end and gesture.start + n.end > cell.start)
patterns = ((reference.id + ':rhythm', pattern),)
due = [a for a in anchors if cell.start <= a.latest and a.earliest < cell.end and not generator.solo.harmonic.anchor_note(a, state.notes)]
holding = next((a for a in anchors if state.last and state.last.role != 'quote' and a.hold_until and a.hold_until > cell.start and generator.solo.harmonic.anchor_note(a, state.notes, until=cell.start)), None)
if holding:
stop = min(cell.end, holding.hold_until)
return [('anchor_sustain', ((cell.start, stop),), {cell.start: holding.pitch}, True)]
result = []
for name, pattern in patterns:
spans = tuple((cell.start + a, cell.start + b) for a, b in pattern)
variants = [(spans, {})]
for anchor in due:
following = []
for current, forced in variants:
onsets = [a for a, b in current if anchor.earliest <= a <= anchor.latest and b - a >= min(anchor.duration, cell.end - a)]
if gesture and gesture.treatment == 'rhythmic_reference':
times = onsets[:2]
else:
times = list(dict.fromkeys(onsets[:2] + [max(cell.start, anchor.earliest)]))
for time in times:
stop = min(cell.end, anchor.hold_until or time + anchor.duration)
original_end = next((b for a, b in current if a == time), stop)
stop = max(stop, original_end)
if gesture and gesture.treatment == 'rhythmic_reference' and stop != original_end:
continue
overlaid = [(a, min(b, time)) for a, b in current if a < time]
overlaid += [(time, stop)] + [(a, b) for a, b in current if a >= stop]
following.append((tuple((a, b) for a, b in overlaid if b > a), {**forced, time: anchor.pitch}))
variants = following
result.extend((name, spans, forced, False) for spans, forced in variants)
return result
def realize_route(spans, first, steps, forced, choices, plan, harmony, contracts, reference):
"""Instantiate relative motion at actual pitches, adapting only at collection changes."""
path = []
for index, (start, end) in enumerate(spans):
available = options(choices, start, end, plan.register)
if not available:
return None
if start in forced:
available = tuple(p for p in available if p.number() == forced[start].number())
if end - start >= generator.solo.policy.DEFAULT.long_note:
members = harmony.members(start, end)
available = tuple(p for p in available if p.number() % 12 in members or any(t.pitch == p.number() and t.start <= start and t.end >= end for t in contracts))
if not available:
return None
intended = first.number()
if path:
prior = path[-1].pitch.number()
scale = harmony.at(start).scale
step, semitones = steps[(index - 1) % len(steps)] if steps else (0, 0)
try:
intended = generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, prior) + step).number() if step is not None else prior + semitones
except ValueError:
# The old pitch is not in the new collection: preserve its sounding
# coordinate and adapt its intended motion, never transfer degree IDs.
intended = prior + semitones
stopping = end - start >= generator.solo.policy.DEFAULT.stopping_note
basic = harmony.at(start).roles.basic
pitch = min(available, key=lambda p: (abs(p.number() - intended) + (0.6 if stopping and p.number() % 12 not in basic else 0), p))
source = reference.notes[min(index, len(reference.notes) - 1)].sources if reference.notes else ()
path.append(generator.solo.types.Note(start, end, pitch, source, 'motif'))
return tuple(path)
def candidates(cell, plan, choices, sequences, state=None, anchors=(), harmony=None):
state = state or generator.solo.types.ContinuationState(plan.passage_id)
harmony = harmony or generator.solo.harmonic.Harmony(choices)
reference = generator.solo.motifs.reference(plan, cell.gesture, state.motifs)
treatment = cell.gesture.treatment if cell.gesture else plan.treatment if cell.motif else 'continue'
contracts = tuple(dict.fromkeys((*state.treatments, *anchor_treatments(anchors, harmony))))
protected = cell.motif and plan.treatment in ('quote', 'exact_quote')
local_plan = dataclasses.replace(plan, start=cell.start, end=cell.end, release=fractions.Fraction(), passages=tuple(dataclasses.replace(p, start=max(cell.start, p.start), end=min(cell.end, p.end)) for p in plan.passages if p.start < cell.end and p.end > cell.start))
evaluation = generator.solo.scoring.Evaluation(local_plan, choices)
for identifier, pattern in cell.patterns:
spans = tuple((cell.start + start, cell.start + end) for start, end in pattern)
active = tuple(i for i, (start, end) in enumerate(spans) if not any(c.scale is None and c.span.start < end and c.span.end > start for c in choices))
local_cell = cell
if len(active) != len(spans):
if cell.motif and plan.treatment in ('quote', 'exact_quote'):
result = []
for identifier, spans, forced, sustain in rhythm_variants(cell, plan, state, anchors, reference):
active = tuple((a, b) for a, b in spans if generator.solo.types.permitted(choices, a, b))
if active != spans:
if protected:
continue
spans = tuple(spans[i] for i in active)
spans = active
identifier += ':harmonic_fragment'
if cell.motif:
references = tuple(cell.motif.notes[min(len(cell.motif.notes) - 1, i * len(cell.motif.notes) // len(pattern))] for i in active)
local_cell = dataclasses.replace(cell, motif=dataclasses.replace(cell.motif, notes=references))
paths = []
if local_cell.motif and spans:
if plan.treatment in ('quote', 'exact_quote') and ':fragment' not in identifier and ':harmonic_fragment' not in identifier and ':displaced' not in identifier:
for octaves in (0, 1, -1, 2, -2):
notes = tuple(generator.solo.types.Note(start, end, dataclasses.replace(source.pitch, octave=source.pitch.octave + octaves), source.sources, 'quote') for (start, end), source in zip(spans, local_cell.motif.notes))
if valid_notes(notes, choices):
paths.append((notes, f'quote octave {octaves:+}', 0))
if plan.treatment not in ('quote', 'exact_quote'):
for mismatch, notes in thematic_paths(local_cell, spans, choices, plan):
treatment = 'fragment' if 'fragment' in identifier else plan.treatment
paths.append((notes, treatment, mismatch))
if not spans:
if not protected:
result.append(Candidate((), identifier, 'space', (generator.solo.scoring.value(evaluation.features(())),), treatments=contracts))
continue
available = options(choices, *spans[0], plan.register)
pending = next((t for t in state.treatments if t.kind == 'suspension' and t.deadline >= cell.start and state.last and state.last.pitch.number() == t.pitch and state.last.end == cell.start and not any(n.pitch.number() == t.resolution and n.start >= t.start for n in state.notes)), None)
if pending:
resolution = next((p for p in available if p.number() == pending.resolution), None)
if resolution is None or spans[0][0] != cell.start:
continue
forced = {**forced, spans[0][0]: resolution}
sustain = False
last = state.last
next_anchor = next((a for a in anchors if a.latest >= cell.start and not generator.solo.harmonic.anchor_note(a, state.notes)), None)
destination = next_anchor.pitch.number() if next_anchor else last.pitch.number() if last else reference.notes[0].pitch.number()
if protected:
paths = []
for octave in (0, 1, -1, 2, -2):
notes = tuple(generator.solo.types.Note(a, b, dataclasses.replace(n.pitch, octave=n.pitch.octave + octave), n.sources, 'quote') for (a, b), n in zip(spans, cell.motif.notes))
if valid_notes(notes, choices):
paths.append((notes, f'quote octave {octave:+}'))
elif sustain and last:
paths = [((generator.solo.types.Note(*spans[0], last.pitch, role='sustain', continuation=True),), 'anchor_sustain')]
else:
paths = [(notes, name, 0) for notes, name in sequence_paths(spans, choices, plan, sequences)]
for notes, treatment, identity in paths:
notes = tuple(dataclasses.replace(n, role='target' if n.role not in ('motif', 'quote') and target and target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches else n.role) for n in notes)
features = evaluation.features(notes)
fidelity = identity if cell.motif else 0
near = last.pitch.number() if last else destination
starts = sorted(available, key=lambda p: (abs(p.number() - near), p))[:3]
if spans[0][0] in forced:
starts = [forced[spans[0][0]]]
scale = harmony.at(spans[0][0]).scale
target = min(available, key=lambda p: abs(p.number() - destination))
paths = []
motif_steps = generator.solo.motifs.interval_steps(reference, harmony)
gesture_start = cell.gesture.start if cell.gesture else cell.start
heard = tuple(n for n in state.notes if n.start >= gesture_start)
cursor = len(heard)
if motif_steps and last:
step, semitones = motif_steps[max(0, cursor - 1) % len(motif_steps)]
try:
expected = generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, last.pitch.number()) + step).number() if step is not None else last.pitch.number() + semitones
except ValueError:
expected = last.pitch.number() + semitones
supported = min(available, key=lambda p: (abs(p.number() - expected), p))
if supported not in starts:
starts.append(supported)
if motif_steps:
motif_steps = motif_steps[cursor % len(motif_steps):] + motif_steps[:cursor % len(motif_steps)]
for first in starts:
displacement = generator.solo.types.scale_index(scale, target.number()) - generator.solo.types.scale_index(scale, first.number())
if next_anchor and next_anchor.earliest > cell.end:
displacement = round(displacement * float((cell.end - cell.start) / (next_anchor.earliest - cell.start)))
direction = (displacement > 0) - (displacement < 0)
routes = [(motif_steps, 'develop ' + reference.id)]
retrieved = sequences.retrieve(len(spans), displacement, direction)
selected = generator.solo.sequences.diverse(retrieved, lambda p: (abs(p.offsets()[-1] - displacement), p.sources), lambda p: generator.solo.sequences.family(p.steps), 4)
for path in selected:
steps = tuple((step, generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, first.number()) + step).number() - first.number()) for step in path.steps)
if motif_steps and treatment != 'rhythmic_reference' and not (cell.gesture and cell.gesture.relationship == 'close'):
# Preserve identifying motion, then let the authored vocabulary
# connect it toward the next anchor. The next cell uses this result.
keep = min(2, len(steps))
steps = tuple(motif_steps[i % len(motif_steps)] for i in range(keep)) + steps[keep:]
routes.append((steps, 'sequences ' + ' + '.join(path.sources) + '; develop ' + reference.id))
for steps, name in routes:
notes = realize_route(spans, first, steps, forced, choices, plan, harmony, contracts, reference)
if notes:
paths.append((notes, name))
if last and last.end == cell.start and spans[0][0] == cell.start and not protected:
# An existing sustain is a real alternative, independent of reattack.
for notes, name in list(paths):
if notes[0].pitch.number() == last.pitch.number() and last.role != 'quote':
paths.append(((dataclasses.replace(notes[0], pitch=last.pitch, continuation=True, role='sustain'), *notes[1:]), 'common_tone; ' + name))
# Plan a prepared suspension from the incoming sound before constructing
# its continuation. The contract survives subsequent search boundaries.
if last and last.role != 'quote' and last.end == cell.start and cell.end - cell.start >= 1 and last.start <= cell.start - fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_preparation)):
span = harmony.at(cell.start)
prepared = last.pitch.number() % 12 in harmony.members(cell.start - fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_preparation)), cell.start)
if prepared and span and span.scale and last.pitch.number() % 12 in span.scale.pitches and last.pitch.number() % 12 not in span.roles.basic:
resolution_time = cell.start + fractions.Fraction(1, 2)
resolving = [p for p in options(choices, resolution_time, min(cell.end, resolution_time + fractions.Fraction(1, 2)), plan.register) if p.number() % 12 in span.roles.basic and harmony.step(last.pitch.number(), p.number(), resolution_time)]
for pitch in resolving:
contract = generator.solo.types.HarmonicTreatment(f'{plan.passage_id}_suspension_{cell.start}_{pitch.number()}', 'suspension', last.pitch.number(), cell.start, resolution_time, pitch.number(), cell.start + fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_resolution)))
for original, name in paths[:4]:
tail = tuple(n for n in original if n.start > resolution_time)
end = tail[0].start if tail else cell.end
suspended = (generator.solo.types.Note(cell.start, resolution_time, last.pitch, role='sustain', continuation=True), generator.solo.types.Note(resolution_time, end, pitch, role='motif'), *tail)
raw = tuple(dataclasses.replace(n, position=None) for n in state.notes)
if not raw:
continue
combined = generator.solo.types.sounding_notes((*raw, *suspended))
checked = harmony.evaluate(combined, (*contracts, contract), until=cell.end)
if checked.valid:
features = evaluation.features(tuple(dataclasses.replace(n, continuation=False) for n in suspended))
features['harmonic_exposure'] = checked.exposure
fidelity = generator.solo.scoring.motif_fidelity(tuple(n for n in combined if n.start >= (cell.gesture.start if cell.gesture else cell.start)), reference, treatment)
features['fidelity'] = fidelity
result.append(Candidate(suspended, identifier + '; prepared_suspension; ' + name, treatment, (generator.solo.scoring.value(features),), fidelity, (*contracts, contract)))
for notes, name in paths:
# Continuations are canonicalized with their incoming sounding event.
raw = tuple(dataclasses.replace(n, position=None) for n in state.notes)
combined = generator.solo.types.sounding_notes((*raw, *notes)) if raw or not notes[0].continuation else ()
if not combined:
continue
harmonic = harmony.evaluate(combined, contracts, until=cell.end)
if not harmonic.valid:
continue
local = tuple(dataclasses.replace(n, continuation=False) for n in notes)
features = evaluation.features(local)
gesture_notes = tuple(n for n in combined if n.start >= (cell.gesture.start if cell.gesture else cell.start))
fidelity = (generator.solo.scoring.motif_fidelity(gesture_notes, reference, treatment) if len(gesture_notes) > 1 else 0.5) if cell.gesture or cell.motif else 0
features['fidelity'] = fidelity
rank = (generator.solo.scoring.value(features),)
result.append(Candidate(notes, identifier, treatment, rank, fidelity))
# A no-harmony passage is intentional silence, not a failed pitch search.
if not (cell.motif and plan.treatment in ('quote', 'exact_quote')) and not any(c.span.chord and c.span.start < cell.end and c.span.end > cell.start for c in choices):
result = [Candidate((), 'no_harmony', 'space', (0,))]
return sorted(result, key=lambda c: (c.rank, c.source, c.treatment, tuple(n.pitch for n in c.notes)))
features['harmonic_exposure'] = harmonic.exposure
features['connection'] = min(1, abs(notes[0].pitch.number() - last.pitch.number()) / 12) if last else 0
result.append(Candidate(notes, identifier + '; ' + name, name if protected else treatment, (generator.solo.scoring.value(features),), fidelity, contracts))
unique = {}
for candidate in sorted(result, key=lambda c: (c.rank, c.source, tuple(n.pitch for n in c.notes))):
unique.setdefault((candidate.notes, candidate.treatments), candidate)
return list(unique.values())
@dataclasses.dataclass
class SearchContext:
pools: dict = dataclasses.field(default_factory=dict)
results: dict = dataclasses.field(default_factory=dict)
evaluations: dict = dataclasses.field(default_factory=collections.OrderedDict)
diagnostics: dict = dataclasses.field(default_factory=lambda: {'searches': 0, 'cache_hits': 0, 'generated_candidates': 0, 'evaluated_extensions': 0, 'peak_states': 0, 'candidate_families': {}, 'surviving_families': {}})
def note_family(notes):
return generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))) if notes else 'space'
def state_identity(state, plan):
_, decisions, last, _, notes = state
ending = (last.pitch, last.position, last.start, last.end) if last else None
times = tuple((float(n.start), float(n.end)) for n in notes)
windows = tuple((float(p.start), float(p.end)) for p in generator.solo.scoring.passages(plan))
activity = tuple((sum(start <= a < end for a, _ in times), sum(max(0, min(b, end) - max(a, start)) for a, b in times)) for start, end in windows)
rhythm = tuple((n.start, n.end) for n in notes[-3:])
progress = tuple(any(p.earliest <= n.start <= p.latest and p.low <= n.pitch.number() <= p.high for n in notes) for p in plan.landmarks)
return ending, activity, rhythm, progress, tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes[-4:], notes[-4:][1:])), tuple(d.treatment for d in decisions)
@dataclasses.dataclass(frozen=True)
class BeamState:
score: float
decisions: tuple
music: generator.solo.types.ContinuationState
travel: int = 0
fidelity: tuple[float, ...] = ()
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
context = context if context is not None else SearchContext()
@@ -259,78 +368,101 @@ def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=No
context.diagnostics['cache_hits'] += 1
return context.results[key]
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] = tuple((cell, candidates(cell, plan, choices, sequences)) for cell in cells(plan, measures, rhythms))
for _, pool in context.pools[plan]:
context.diagnostics['generated_candidates'] += len(pool)
for candidate in pool:
family = note_family(candidate.notes)
counts = context.diagnostics['candidate_families']
counts[family] = counts.get(family, 0) + 1
states = [(0.0, (), previous, 0, ())]
for cell, pool in context.pools[plan]:
evaluation = generator.solo.scoring.Evaluation(plan, choices, cell.end)
following, extensions = [], {}
for _, decisions, last, travel, sounded in states:
target_met = target_ok(sounded, plan.target)
def extension_rank(candidate):
jump = abs(candidate.notes[0].pitch.number() - last.pitch.number()) / 12 if candidate.notes and last else 0
return candidate.rank[0] + min(1, jump), candidate.source, candidate.treatment, tuple(n.pitch for n in candidate.notes)
extension_key = (last.pitch if last else None, target_met)
if extension_key not in extensions:
eligible = [c for c in pool if not plan.target or plan.target.latest >= cell.end and cell.end < plan.end or target_met or target_ok(c.notes, plan.target)]
extensions[extension_key] = generator.solo.sequences.diverse(eligible, extension_rank, lambda c: (len(c.notes), note_family(c.notes), c.treatment if cell.motif else ''), CELL_LIMIT)
for candidate in extensions[extension_key]:
variants = [candidate.notes]
passage = next((p for p in plan.passages if p.start <= cell.start < p.end), None)
if cell.motif is None and last and last.end == cell.start and len(candidate.notes) == 1 and candidate.notes[0].start == cell.start and candidate.notes[0].pitch == last.pitch and passage and passage.intent == 'sustained_arrival':
variants.append((dataclasses.replace(candidate.notes[0], continuation=True, role='sustain'),))
for variant in variants:
context.diagnostics['evaluated_extensions'] += 1
placed = generator.solo.placement.place(variant, tempo, last)
if placed is None:
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
comfort, notes = placed
decision = generator.solo.types.Decision(cell.start, cell.end, candidate.source, candidate.treatment, notes, candidate.fidelity)
combined = (*decisions, decision)
canonical = generator.solo.types.sounding_notes((*sounded, *notes))
if plan.target and (plan.target.latest < cell.end or cell.end == plan.end) and not target_ok(canonical, plan.target):
continue
musical_key = (plan, cell.end, tuple((n.start, n.end, n.pitch, n.role) for n in canonical))
if musical_key not in context.evaluations:
context.evaluations[musical_key] = evaluation.features(canonical) if evaluation.feasible(canonical) else None
if len(context.evaluations) > 32768:
context.evaluations.popitem(last=False)
else:
context.evaluations.move_to_end(musical_key)
evaluated = context.evaluations[musical_key]
if evaluated is None:
continue
features = dict(evaluated)
features['connection'] = min(1, abs(canonical[0].pitch.number() - previous.pitch.number()) / 12) if canonical and previous else 0
features['movement'] = min(1, (travel + comfort) / max(1, len(canonical)) / 2000)
total = generator.solo.scoring.value(features)
following.append((total, combined, canonical[-1] if canonical else last, travel + comfort, canonical))
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; no feasible phrase extension among {BEAM_WIDTH} states / {CELL_LIMIT} extensions (not proof that the passage is unplayable)')
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[0], tuple((n.pitch, n.position, n.start, n.end) for n in state[4])
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_identity(state, plan), state)
states = list(generator.solo.sequences.diverse(unique.values(), rank, lambda s: (note_family(s[4]), len(s[4]), s[4][-1].pitch.number() if s[4] else None), BEAM_WIDTH))
states.sort(key=rank)
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[0], tuple(n.pitch for n in s[4])), lambda s: (note_family(s[4]), len(s[4]), s[4][-1].pitch if s[4] else None), generator.solo.policy.DEFAULT.alternatives)
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[4])
family = note_family(state.music.notes[-4:])
counts = context.diagnostics['surviving_families']
counts[family] = counts.get(family, 0) + 1
result = tuple(generator.solo.types.Phrase(plan, decisions, score) for score, decisions, _, _, _ in selected)
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
+47 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import dataclasses
import json
import fractions
import generator.harmony
import generator.model
@@ -9,6 +10,7 @@ import generator.scales.report
import generator.scales.selection
import generator.scales.timeline
import generator.solo.types
import generator.solo.harmonic
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')
@@ -16,16 +18,29 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
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))
detail = structure(solo)
for passage in detail['passages']:
lines.append(f"Melodic passage {passage['id']}: {passage['start']}-{passage['end']}; phrases {', '.join(passage['phrases'])}")
lines.append(f"Harmonic exposure: {detail['harmonic_exposure']:.4f}; unresolved treatments: {len(detail['unresolved_treatments'])}")
for phrase in solo.phrases:
plan = phrase.plan
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}')
for passage in plan.passages:
lines.append(f' Passage {passage.start}-{passage.end}: {passage.intent}; attacks/beat {float(passage.activity):.3f}; occupancy {float(passage.occupancy):.2f}')
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:
lines.append(f' Landmark {landmark.name}: {landmark.earliest}-{landmark.latest}; sounding MIDI {landmark.low}-{landmark.high}; direction {landmark.direction}')
if plan.target:
lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}')
for anchor in phrase.anchors:
actual = generator.solo.harmonic.anchor_note(anchor, phrase.state.notes if phrase.state else solo.notes())
lines.append(f' Anchor {anchor.id}: {anchor.role}; {anchor.chord}; {anchor.pitch.label()}; window {anchor.earliest}-{anchor.latest}; hold {anchor.duration}; hold until {anchor.hold_until}; completed {actual is not None}')
if phrase.state:
valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=phrase.plan.end == phrase.plan.passage_end, closure=phrase.plan.closure)
lines.append(f' Ending contract fulfilled: {valid}; {reason}')
for motif in phrase.state.motifs:
if phrase.plan.start <= motif.source_start < phrase.plan.end:
lines.append(f' Realized motif {motif.id}: parent {motif.parent}; {motif.relationship}; {motif.treatment}; pitches ' + ' '.join(n.pitch.label() for n in motif.notes) + f'; rhythm ' + ' '.join(f'{n.start}:{n.end}' for n in motif.notes))
for decision in phrase.decisions:
lines.append(f' {decision.start}-{decision.end}: {decision.source}; {decision.treatment}')
if not decision.notes:
@@ -36,4 +51,35 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12]
function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection'
lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}')
lines.append('\nSounding harmonic spans (ties merged before classification):')
for event in detail['harmonic_roles']:
if float(fractions.Fraction(event['sounding_duration'])) >= 1 or event['protected']:
lines.append(' ' + json.dumps(event, sort_keys=True))
return '\n'.join(lines) + '\n'
def structure(solo):
harmony = generator.solo.harmonic.Harmony(solo.choices)
contracts = tuple(dict.fromkeys(t for p in solo.phrases if p.state for t in p.state.treatments))
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True)
def rational(value):
if isinstance(value, dict):
return {key: rational(item) for key, item in value.items()}
if isinstance(value, (tuple, list, set, frozenset)):
return [rational(item) for item in value]
if isinstance(value, fractions.Fraction):
return str(value)
return value
phrases = []
for phrase in solo.phrases:
notes = phrase.state.notes if phrase.state else solo.notes()
complete = phrase.plan.end == (phrase.plan.passage_end or phrase.plan.end)
valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, notes, phrase.plan.end, complete=complete, closure=phrase.plan.closure)
anchors = []
for anchor in phrase.anchors:
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])})
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])}
+8 -5
View File
@@ -10,8 +10,8 @@ def passages(plan):
if plan.passages:
return plan.passages
end = plan.end - plan.release
active = (generator.solo.types.Passage(plan.start, end, 'active', plan.activity, fractions.Fraction(3, 4)),) if end > plan.start else ()
return active + ((generator.solo.types.Passage(end, plan.end, 'space', fractions.Fraction(), fractions.Fraction()),) if plan.release else ())
active = (generator.solo.types.ActivitySpan(plan.start, end, 'active', plan.activity, fractions.Fraction(3, 4)),) if end > plan.start else ()
return active + ((generator.solo.types.ActivitySpan(end, plan.end, 'space', fractions.Fraction(), fractions.Fraction()),) if plan.release else ())
def intervals(choices, start, end):
return tuple((max(start, c.span.start), min(end, c.span.end)) for c in choices if c.scale and c.span.start < end and c.span.end > start)
@@ -23,12 +23,14 @@ def corridor(pitch, low, high):
return max(low - pitch, 0, pitch - high)
def motif_fidelity(notes, motif, treatment):
if treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
if treatment in ('quote', 'exact_quote'):
return 0.0
if treatment == 'rhythmic_reference':
return 1.0 if len(notes) < min(2, len(motif.notes)) else 0.0
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
expected = motif.contour()
if not actual or not expected:
return 0.0 if len(notes) >= min(1, len(motif.notes)) else 1.0
return 0.0 if len(notes) == len(motif.notes) == 1 else 1.0
count = min(2, len(actual), len(expected)) if treatment == 'opening_reference' else max(1, len(actual) // 2) if treatment == 'answer' else len(actual)
errors = []
for index in range(count):
@@ -36,7 +38,8 @@ def motif_fidelity(notes, motif, treatment):
motion = actual[index]
direction = ((reference > 0) - (reference < 0)) != ((motion > 0) - (motion < 0))
errors.append(0.75 * direction + 0.25 * min(1, abs(motion - reference) / 12))
return sum(errors) / len(errors)
evidence = min(2, len(expected))
return max(sum(errors) / len(errors), max(0, evidence - len(actual)) / evidence)
class Evaluation:
"""Prepare fixed harmonic windows once; only soft penalties use float time."""
+89 -2
View File
@@ -65,13 +65,80 @@ class TargetEvent:
reason: str
@dataclasses.dataclass(frozen=True)
class Passage:
class ActivitySpan:
start: fractions.Fraction
end: fractions.Fraction
intent: str
activity: fractions.Fraction
occupancy: fractions.Fraction
@dataclasses.dataclass(frozen=True)
class HarmonicTreatment:
id: str
kind: str
pitch: int
start: fractions.Fraction
end: fractions.Fraction
resolution: int | None = None
deadline: fractions.Fraction | None = None
preparation: fractions.Fraction = fractions.Fraction(1, 2)
@dataclasses.dataclass(frozen=True)
class Anchor:
id: str
earliest: fractions.Fraction
latest: fractions.Fraction
pitch: Pitch
eligible: frozenset[int]
register: tuple[int, int]
duration: fractions.Fraction
role: str
chord: str
onset_required: bool = False
hold_until: fractions.Fraction | None = None
@dataclasses.dataclass(frozen=True)
class Gesture:
id: str
start: fractions.Fraction
end: fractions.Fraction
relationship: str
treatment: str
@dataclasses.dataclass(frozen=True)
class RealizedMotif:
id: str
parent: str
notes: tuple[Note, ...]
duration: fractions.Fraction
relationship: str
treatment: str
chord_degrees: tuple[tuple[int, ...], ...] = ()
source_start: fractions.Fraction = fractions.Fraction()
def contour(self):
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
@dataclasses.dataclass(frozen=True)
class ContinuationState:
passage_id: str
notes: tuple[Note, ...] = ()
motifs: tuple[RealizedMotif, ...] = ()
treatments: tuple[HarmonicTreatment, ...] = ()
anchors: tuple[str, ...] = ()
direction: int = 0
last: Note | None = None
backbone: int = 0
fidelity: tuple[float, ...] = ()
travel: int = 0
@dataclasses.dataclass(frozen=True)
class MelodicPassagePlan:
id: str
start: fractions.Fraction
end: fractions.Fraction
phrase_ids: tuple[str, ...]
seed: str
@dataclasses.dataclass(frozen=True)
class Landmark:
earliest: fractions.Fraction
@@ -93,11 +160,17 @@ class PhrasePlan:
register: tuple[int, int]
activity: fractions.Fraction
release: fractions.Fraction = fractions.Fraction(1, 2)
passages: tuple[Passage, ...] = ()
passages: tuple[ActivitySpan, ...] = ()
landmarks: tuple[Landmark, ...] = ()
ending: str = 'breath'
closure: str = 'closed'
seed: str = ''
passage_id: str = ''
passage_start: fractions.Fraction | None = None
passage_end: fractions.Fraction | None = None
anchor_options: tuple[tuple[Anchor, ...], ...] = ()
gestures: tuple[Gesture, ...] = ()
treatments: tuple[HarmonicTreatment, ...] = ()
@dataclasses.dataclass(frozen=True)
class Decision:
@@ -107,12 +180,16 @@ class Decision:
treatment: str
notes: tuple[Note, ...]
fidelity: float = 0
gesture_id: str = ''
parent_motif: str = ''
@dataclasses.dataclass(frozen=True)
class Phrase:
plan: PhrasePlan
decisions: tuple[Decision, ...]
score: float = 0
state: ContinuationState | None = None
anchors: tuple[Anchor, ...] = ()
@dataclasses.dataclass(frozen=True)
class Solo:
@@ -122,6 +199,7 @@ class Solo:
tempo: fractions.Fraction
end: fractions.Fraction
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
passages: tuple[MelodicPassagePlan, ...] = ()
def notes(self) -> tuple[Note, ...]:
return sounding_notes(n for p in self.phrases for d in p.decisions for n in d.notes)
@@ -136,6 +214,15 @@ def scale_pitch(scale: generator.scales.library.Scale, index: int, octave: int =
alter = generator.model.alteration(name['alter'])
return Pitch(name['letter'], alter, int((number - generator.model.NATURAL[name['letter']] - alter) // 12 - 1))
@functools.lru_cache(maxsize=4096)
def scale_index(scale: generator.scales.library.Scale, number: int, octave: int = 4) -> int:
"""Exact inverse by sounding pitch; spelling never moves a held note."""
root = scale_pitch(scale, 0, octave).number()
turns, chromatic = divmod(number - root, 12)
if chromatic not in scale.pattern.intervals:
raise ValueError(f'{number} is outside {scale.label()}')
return 7 * turns + scale.pattern.intervals.index(chromatic)
def permitted(choices: tuple[generator.scales.selection.Choice, ...], start: fractions.Fraction, end: fractions.Fraction) -> frozenset[int]:
covered = start
pitches = frozenset(range(12))