+40
-16
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, ''
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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])}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user