Soloing
ci / checks (push) Successful in 1h53m40s

This commit is contained in:
khodowany
2026-10-01 11:23:47 -05:00
parent 75655786e9
commit 5414caf30a
14 changed files with 1085 additions and 259 deletions
+7 -1
View File
@@ -95,7 +95,13 @@ Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live
`data/songs` supplies the authored music. `data/rhythms/<meter>/<series>/` supplies the accompaniment rhythm library for 2/4, 3/4, 4/4, and 5/4. All three series are active: the original quarter/eighth patterns plus series `3` with half, dotted-half, and whole notes/rests. Rhythm snippets are build inputs, not separate catalog entries or runtime files. Generation loads the library once and shares it across parts.
`data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. The Python build uses these collections, the song melody, `data/sequences`, and `data/rhythms` to compose acoustic and electric solos with transformed motifs, connected melodic lines, varying activity, sustained arrivals, and planned space. Phrase landmarks guide motion across barlines; a bounded search considers the next phrase before committing. Exact quotation is available but is no longer the default. The other guitar provides solo-aware accompaniment. Scale changes appear above the solo notation and TAB; detailed build-only phrase reports live in `generated_assets/analysis/`. No solo assignments are stored in song XML. Invalid patterns or a harmony without an exact permitted scale fail before replacing the previous output.
`data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. The Python build uses these collections, the song melody, `data/sequences`, and `data/rhythms` to compose acoustic and electric solos with transformed motifs, connected melodic lines, varying activity, sustained arrivals, and planned space. A passage plan groups roughly four to eight bars, connects opening, intermediate and terminal chord-tone anchors, and carries realized motif identity across smaller search cells and phrase boundaries. A bounded search considers the next phrase before committing. Exact quotation is available but is no longer the default. The other guitar provides solo-aware accompaniment. Scale changes appear above the solo notation and TAB; detailed build-only phrase reports live in `generated_assets/analysis/`. No solo assignments are stored in song XML. Invalid patterns or a harmony without an exact permitted scale fail before replacing the previous output.
Solo harmonic function is distinct from scale permission. Basic members respect the chord's suspensions, alterations and omissions; symbol-specified extensions require a planned color when sustained, and a non-chord slash bass is not automatically a melodic destination. Ordinary notes lasting two quarter-note beats or longer must remain chord members across every overlapping chord, even when the scale stays unchanged. Short scale motion remains available. A prepared suspension must resolve by the planned deadline to an adjacent chord member. These checks operate on complete sounding events, so notation ties do not change harmonic treatment or attack counts. Protected quotations keep their musical content and report exposed tensions.
Concrete continuations start from the actual incoming pitch and guitar position. Relative routes from `data/sequences` use exact octave-aware scale indices; a collection change adapts actual pitches without transferring stale degree numbers. Realized statements supply subsequent rhythm, contour, harmonic-anchor and transformation history. Intermediate arrivals permit continuation; terminal arrivals must meet their duration and ending contract. Activity and occupancy targets remain separate from these harmonic requirements. Search budgets remain 64 states, 32 extensions and four retained alternatives, with a rolling gesture window and at most two explicit development-contract replans. Exhaustion reports the failed continuation or contract, not a claim of musical impossibility.
For reproducible listening comparisons, `python -m generator.solo.audition --output build/solo_passage_auditions --capture before` captures the previous implementation; run the corresponding `--capture after` after changing the engine, then `--render`. Use the repository's pinned Python runner. The output includes full solos, opening eight-bar and closing four-bar excerpts, MusicXML, passage reports and structured diagnostics. Fixed-backing comparisons keep the original accompaniment; responsive comparisons use the newly arranged accompaniment. These build-only tools do not add runtime data or playback expression.
To inspect generated assets without building the full package, run:
+40 -16
View File
@@ -94,7 +94,7 @@ def capture(root, stage):
regions = generator.solo.arrangement.regions(document)
for solo, index in zip(solos, (3, 4)):
first, count, offset, length = regions[index]
song['solos'][solo.part] = {'first': first, 'count': count, 'offset': str(offset), 'length': str(length), 'tempo': str(solo.tempo), 'metrics': metrics(solo), 'report': generator.solo.report.describe(solo, scales)}
song['solos'][solo.part] = {'first': first, 'count': count, 'offset': str(offset), 'length': str(length), 'tempo': str(solo.tempo), 'metrics': metrics(solo), 'report': generator.solo.report.describe(solo, scales), 'structure': generator.solo.report.structure(solo)}
print(path.stem, solo.part, json.dumps(song['solos'][solo.part]['metrics']), flush=True)
result['songs'][path.stem] = song
destination.mkdir(parents=True)
@@ -152,27 +152,33 @@ def mix(documents, tempo, root, cache):
return output
def render(root):
snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in STAGES}
stages = ('before', 'after') if (root / 'before' / 'snapshot.json').exists() else STAGES
snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in stages}
cache, clips, manifest = {}, [], {'sample_rate': generator.playback.SAMPLE_RATE, 'stages': {}, 'clips': []}
for stage, snapshot in snapshots.items():
manifest['stages'][stage] = {k: v for k, v in snapshot.items() if k != 'songs'}
for song_name, song in snapshot['songs'].items():
baseline = snapshots['baseline']['songs'][song_name]
baseline = snapshots[stages[0]]['songs'][song_name]
if song['source_sha256'] != baseline['source_sha256']:
raise ValueError(f'{song_name}: source changed between audition stages')
for part, solo in song['solos'].items():
other = 'electric' if part == 'acoustic' else 'acoustic'
first, count = solo['first'], min(8, solo['count'])
foreground = excerpt(song['parts'][part], first, count)
prefix = root / stage / f'{song_name}_{part}'
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), foreground)
prefix.with_suffix('.txt').write_text(solo['report'])
for responsive in [False, True] if stage == 'D' else [False]:
backing = excerpt((song if responsive else baseline)['parts'][other], first, count)
path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav')
samples = mix((foreground, backing), fractions.Fraction(solo['tempo']), root, cache)
clips.append((path, samples))
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
windows = [('full', 0, solo['count']), ('opening8', 0, min(8, solo['count'])), ('closing4', max(0, solo['count'] - 4), min(4, solo['count']))] if stages[0] == 'before' else [('', 0, min(8, solo['count']))]
for label, offset, count in windows:
first = solo['first'] + offset
foreground = excerpt(song['parts'][part], first, count)
prefix = root / stage / (f'{song_name}_{part}' + ('_' + label if label else ''))
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), foreground)
prefix.with_suffix('.txt').write_text(solo['report'])
if 'structure' in solo:
prefix.with_suffix('.json').write_text(json.dumps(solo['structure'], indent=2) + '\n')
for responsive in [False, True] if stage == stages[-1] else [False]:
backing = excerpt((song if responsive else baseline)['parts'][other], first, count)
vocal = excerpt(baseline['parts']['vocal'], first, count)
path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav')
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
clips.append((path, samples))
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'window': label, 'first': offset, 'count': count, 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
# One common attenuation preserves relative loudness and identical backing.
peak = max(abs(value) for _, samples in clips for value in samples)
gain = min(1, 0.95 / peak) if peak else 1
@@ -180,7 +186,7 @@ def render(root):
for path, samples in clips:
generator.playback.write_pcm(path, (value * gain for value in samples))
(root / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
write_index(root, snapshots)
write_passage_index(root, snapshots) if stages[0] == 'before' else write_index(root, snapshots)
print(f'Rendered {len(clips)} comparisons under {root}', flush=True)
def write_index(root, snapshots):
@@ -197,11 +203,29 @@ def write_index(root, snapshots):
body.append(f'</table><p>D with responsive backing: <audio controls preload="none" src="D/{name}_{part}_responsive.wav"></audio></p>')
(root / 'index.html').write_text(''.join(body) + '</html>\n')
def write_passage_index(root, snapshots):
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Connected solo passages</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:.75rem;border-bottom:1px solid #ccd1c9}audio{width:260px}small{display:block;margin-top:.5rem}</style><h1>Connected solo passages</h1><p>Before and after use the same source songs, scale-selection settings, activity targets, tempo and root seed inputs. Fixed comparisons retain the original backing. Responsive comparisons include accompaniment recomposed around the new foreground. The returning vocal pickup and one shared mix gain are preserved.</p><p>Listen for a convincing settlement on the current chord, an idea developing across several bars, intermediate arrivals that continue, and a prepared ending. Metrics alone do not establish musical improvement. <a href="manifest.json">Fingerprints and diagnostics</a>.</p>']
for name, song in snapshots['after']['songs'].items():
for part in song['solos']:
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2>')
for stage in ('before', 'after'):
metric = snapshots[stage]['songs'][name]['solos'][part]['metrics']
body.append(f"<p>{stage.title()}: {metric['attacks_per_four_beats']:.2f} attacks / four beats; {metric['sounding_occupancy']:.1%} occupancy; longest unplanned gap {metric['longest_unplanned_gap_beats']:g} beats.</p>")
body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>After, responsive backing</th></tr>')
for window in ('full', 'opening8', 'closing4'):
body.append(f'<tr><th>{window}</th>')
for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')):
prefix = stage + '/' + name + '_' + part + '_' + window
body.append(f'<td><audio controls preload="none" src="{prefix}_{mix_name}.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a>' + (f' · <a href="{prefix}.json">Passage data</a>' if stage == 'after' else '') + '</small></td>')
body.append('</tr>')
body.append('</table>')
(root / 'index.html').write_text(''.join(body) + '</html>\n')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_auditions'))
action = parser.add_mutually_exclusive_group(required=True)
action.add_argument('--capture', choices=STAGES)
action.add_argument('--capture', choices=(*STAGES, 'before', 'after'))
action.add_argument('--render', action='store_true')
args = parser.parse_args()
capture(args.output, args.capture) if args.capture else render(args.output)
+41 -6
View File
@@ -8,6 +8,7 @@ import generator.harmony
import generator.model
import generator.scales.selection
import generator.scales.timeline
import generator.solo.harmonic
import generator.solo.analysis
import generator.solo.planning
import generator.solo.policy
@@ -31,17 +32,37 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
context = generator.solo.realization.SearchContext()
def realize(plan, previous):
return generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context)
original = plan
for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1):
try:
options = generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context)
if attempt:
context.results[(original, previous)] = options
return options
except generator.solo.realization.SearchExhausted as error:
if attempt == generator.solo.policy.DEFAULT.replan_attempts or plan.treatment in ('quote', 'exact_quote'):
raise
context.diagnostics.setdefault('replanning', []).append({'phrase': plan.id, 'attempt': attempt + 1, 'reason': str(error), 'adjustment': 'contour reference' if attempt == 0 else 'opening reference'})
# Replan only ordinary development contracts; harmony, anchor pitches,
# required holds, timing, activity and protected quotations stay fixed.
treatment = 'contour_reference' if attempt == 0 else 'opening_reference'
gestures = tuple(dataclasses.replace(g, treatment=treatment) if g.relationship not in ('statement', 'close') else g for g in plan.gestures)
plan = dataclasses.replace(plan, gestures=gestures)
phrases = choose_phrases(plans, realize)
context.diagnostics['policy'] = generator.solo.policy.describe()['fingerprint']
context.diagnostics['musical_identity'] = generator.solo.analysis.identity(analysis, measures, part)
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end, context.diagnostics)
passage_ids = tuple(dict.fromkeys(p.passage_id for p in plans if p.passage_id))
passages = tuple(generator.solo.types.MelodicPassagePlan(identifier, min(p.start for p in plans if p.passage_id == identifier), max(p.end for p in plans if p.passage_id == identifier), tuple(p.id for p in plans if p.passage_id == identifier), next(p.seed for p in plans if p.passage_id == identifier)) for identifier in passage_ids)
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end, context.diagnostics, passages)
validate(result)
return result
def phrase_notes(phrase):
return generator.solo.types.sounding_notes(n for d in phrase.decisions for n in d.notes)
notes = tuple(n for d in phrase.decisions for n in d.notes)
if notes and notes[0].continuation:
notes = (dataclasses.replace(notes[0], continuation=False), *notes[1:])
return generator.solo.types.sounding_notes(notes)
def transition_cost(first, second):
before, after = phrase_notes(first), phrase_notes(second)
@@ -70,20 +91,23 @@ def choose_phrases(plans, realize):
ranked.append((option.score + incoming, option))
continue
try:
following = realize(plans[index + 1], last)
following = realize(plans[index + 1], option.state or last)
except generator.solo.realization.SearchExhausted as error:
failure = error
continue
length = float(option.plan.end - option.plan.start)
next_length = float(plans[index + 1].end - plans[index + 1].start)
cost = min((option.score * length + future.score * next_length) / (length + next_length) + transition_cost(option, future) for future in following)
shared_passage = bool(plan.passage_id and plan.passage_id == plans[index + 1].passage_id)
cost = min((future.score if shared_passage else (option.score * length + future.score * next_length) / (length + next_length)) + transition_cost(option, future) for future in following)
ranked.append((cost + incoming, option))
if not ranked:
raise failure or generator.solo.realization.SearchExhausted(f'{plan.id}: no feasible phrase transition')
_, chosen = min(ranked, key=lambda item: (item[0], tuple((n.pitch, n.start, n.end, n.position) for n in phrase_notes(item[1]))))
result.append(chosen)
notes = phrase_notes(chosen)
if notes:
if chosen.state:
previous = chosen.state
elif notes:
previous = notes[-1]
return tuple(result)
@@ -101,3 +125,14 @@ def validate(solo):
notes = phrase_notes(phrase)
if not generator.solo.realization.target_ok(notes, phrase.plan.target):
raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized')
harmony = generator.solo.harmonic.Harmony(solo.choices)
contracts = tuple(dict.fromkeys(t for phrase in solo.phrases if phrase.state for t in phrase.state.treatments))
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True)
if not checked.valid:
raise ValueError(f'{solo.part} solo: {checked.error}')
for phrase in solo.phrases:
if phrase.state:
complete = phrase.plan.end == (phrase.plan.passage_end or phrase.plan.end)
valid, error = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=complete, closure=phrase.plan.closure)
if not valid:
raise ValueError(error)
+162
View File
@@ -0,0 +1,162 @@
"""Chord function on exact sounding intervals, separate from scale permission."""
from __future__ import annotations
import dataclasses
import fractions
import functools
import generator.harmony
import generator.model
import generator.solo.policy
import generator.solo.types
@dataclasses.dataclass(frozen=True)
class Roles:
basic: frozenset[int]
specified: frozenset[int]
degrees: tuple[tuple[int, tuple[int, ...]], ...]
@functools.lru_cache(maxsize=512)
def degree_roles(kind, root, degrees):
basic, specified, labels = set(), set(), {}
for degree, alteration in degrees:
pitch = int(root + generator.harmony.degree_semitones(degree, alteration)) % 12
structural = degree in (1, 3, 5, 6, 7) or degree in (2, 4) and kind in ('suspended-second', 'suspended-fourth')
(basic if structural else specified).add(pitch)
labels.setdefault(pitch, []).append(degree)
return Roles(frozenset(basic), frozenset(specified - basic), tuple((p, tuple(v)) for p, v in sorted(labels.items())))
def roles(chord):
if not chord or chord['kind'] == 'none':
return Roles(frozenset(), frozenset(), ())
root = chord['root']
number = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
return degree_roles(chord['kind'], number, tuple(sorted(generator.harmony.degrees(chord).items())))
@dataclasses.dataclass(frozen=True)
class Span:
start: fractions.Fraction
end: fractions.Fraction
roles: Roles
scale: object
chord: str
@dataclasses.dataclass(frozen=True)
class Result:
valid: bool
exposure: float
pending: tuple[generator.solo.types.HarmonicTreatment, ...] = ()
error: str = ''
class Harmony:
def __init__(self, choices):
self.choices = choices
self.spans = tuple(Span(c.span.start, c.span.end, roles(c.span.chord), c.scale, generator.harmony.symbol(c.span.chord) if c.span.chord else 'N.C.') for c in choices)
def at(self, time):
return next((s for s in self.spans if s.start <= time < s.end), None)
def segments(self, note):
return tuple((s, max(note.start, s.start), min(note.end, s.end)) for s in self.spans if s.start < note.end and s.end > note.start)
def members(self, start, end):
spans = [s for s in self.spans if s.start < end and s.end > start]
return frozenset.intersection(*(s.roles.basic for s in spans)) if spans else frozenset()
def function(self, number, span):
pitch = number % 12
return 'basic' if pitch in span.roles.basic else 'specified_extension' if pitch in span.roles.specified else 'mapped_motion' if span.scale and pitch in span.scale.pitches else 'outside_collection'
def step(self, first, second, time):
span = self.at(time)
if not span or not span.scale:
return False
try:
return abs(generator.solo.types.scale_index(span.scale, first) - generator.solo.types.scale_index(span.scale, second)) == 1
except ValueError:
return False
def connected_motion(self, notes, index):
if not 0 < index < len(notes) - 1:
return ''
before, note, after = notes[index - 1:index + 2]
if before.end != note.start or note.end != after.start:
return ''
a, b, c = (n.pitch.number() for n in (before, note, after))
if self.step(a, b, note.start) and self.step(b, c, note.start):
return 'neighbor' if a == c else 'passing' if (b - a) * (c - b) > 0 else ''
return ''
def evaluate(self, notes, treatments=(), *, until=None, complete=False):
notes = generator.solo.types.sounding_notes(notes)
until = until if until is not None else max((n.end for n in notes), default=fractions.Fraction())
pending, valid_contracts = [], []
for treatment in treatments:
if treatment.start >= until:
continue
if treatment.kind == 'color':
spans = [s for s in self.spans if s.start < treatment.end and s.end > treatment.start]
if not spans or any(treatment.pitch % 12 not in s.roles.basic | s.roles.specified for s in spans):
return Result(False, 0, error=f'{treatment.id}: color is not specified by the overlapped harmony')
valid_contracts.append(treatment)
continue
if treatment.kind != 'suspension' or treatment.resolution is None or treatment.deadline is None:
return Result(False, 0, error=f'{treatment.id}: incomplete harmonic treatment')
held = next((n for n in notes if n.pitch.number() == treatment.pitch and n.start <= treatment.start - treatment.preparation and n.end > treatment.start), None)
if held is None:
return Result(False, 0, error=f'{treatment.id}: suspension lacks sounding preparation')
if treatment.pitch % 12 not in self.members(treatment.start - treatment.preparation, treatment.start):
return Result(False, 0, error=f'{treatment.id}: preparation is not a chord member')
resolution = next((n for n in notes if n.start == held.end and n.pitch.number() == treatment.resolution and n.start <= treatment.deadline), None)
if resolution:
span = self.at(resolution.start)
if not span or treatment.resolution % 12 not in span.roles.basic or not self.step(treatment.pitch, treatment.resolution, resolution.start):
return Result(False, 0, error=f'{treatment.id}: resolution is not an adjacent chord member')
elif complete or until > treatment.deadline or any(n.start >= held.end for n in notes if n is not held):
return Result(False, 0, error=f'{treatment.id}: unresolved suspension at deadline {treatment.deadline}')
else:
pending.append(treatment)
valid_contracts.append(treatment)
exposed, sounding = 0.0, 0.0
for index, note in enumerate(notes):
number = note.pitch.number()
duration = note.end - note.start
sounding += float(duration)
covered = note.start
for span, start, end in self.segments(note):
if start > covered or not span.scale or number % 12 not in span.scale.pitches:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
covered = end
function = self.function(number, span)
if function == 'basic' or note.role == 'quote':
continue
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None)
if duration >= generator.solo.policy.DEFAULT.long_note and contract is None:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment')
connected = duration < generator.solo.policy.DEFAULT.stopping_note and self.connected_motion(notes, index)
cost = 0 if contract or connected else 1 if duration >= generator.solo.policy.DEFAULT.stopping_note else 0.25
exposed += float(end - start) * cost
if covered < note.end:
return Result(False, 0, error=f'{note.pitch.label()} at {covered}: missing harmonic coverage')
return Result(True, exposed / max(sounding, 0.001), tuple(pending))
def describe(self, notes, treatments=()):
notes = generator.solo.types.sounding_notes(notes)
result = []
for index, note in enumerate(notes):
for span, start, end in self.segments(note):
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'protected': note.role == 'quote', 'treatments': [t.id for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]})
return result
def anchor_note(anchor, notes, *, until=None):
for note in notes:
if note.pitch.number() != anchor.pitch.number():
continue
arrival = note.start if anchor.onset_required else max(note.start, anchor.earliest)
needed = max(arrival + anchor.duration, anchor.hold_until or arrival)
if anchor.earliest <= arrival <= anchor.latest and note.start <= arrival < note.end and note.end >= min(needed, until if until is not None else needed):
return note
return None
def anchors_valid(anchors, notes, until, *, complete=False, closure='open'):
for anchor in anchors:
if anchor.earliest >= until and not complete:
continue
note = anchor_note(anchor, notes, until=None if complete else until)
if note is None and (complete or until > anchor.latest):
return False, f'{anchor.id}: required {anchor.role} arrival {anchor.earliest}-{anchor.latest} on {anchor.pitch.label()} not completed'
if note and anchor.role == 'terminal' and closure == 'closed' and any(n.start > note.start for n in notes):
return False, f'{anchor.id}: terminal anchor touched then abandoned'
return True, ''
+56
View File
@@ -0,0 +1,56 @@
"""Realized gesture identities and their explicit development relationships."""
from __future__ import annotations
import dataclasses
import fractions
import generator.solo.harmonic
import generator.solo.scoring
import generator.solo.types
def reference(plan, gesture, realized):
if not realized or gesture is None or gesture.relationship == 'statement':
return plan.motif
# Repetition returns to the introduced identity; extension/answer develops
# the most recently heard related gesture, never an unrelated catalog winner.
return realized[0] if gesture.relationship == 'repeat_with_harmonic_adaptation' else realized[-1]
def local_notes(notes, start, end):
return tuple(dataclasses.replace(n, start=n.start - start, end=min(n.end, end) - start, position=None, continuation=False) for n in notes if start <= n.start < end)
def complete(gesture, reference, notes, harmony):
actual = local_notes(notes, gesture.start, gesture.end)
sounded = any(n.start < gesture.end and n.end > gesture.start for n in notes)
active = any(s.scale and s.start < gesture.end and s.end > gesture.start for s in harmony.spans)
if not active:
return True, 0.0, None
if gesture.relationship == 'close':
return sounded, 0.0, None
required = min(len(reference.notes), 2)
if len(actual) < required:
return False, 1.0, None
fidelity = generator.solo.scoring.motif_fidelity(actual, reference, gesture.treatment)
if gesture.treatment == 'rhythmic_reference':
expected = tuple((n.start, min(n.end, gesture.end - gesture.start)) for n in reference.notes if n.start < gesture.end - gesture.start)
if tuple((n.start, n.end) for n in actual) != expected:
return False, 1.0, None
elif gesture.relationship != 'statement' and fidelity >= 0.75:
return False, fidelity, None
degrees = []
for n in actual:
span = harmony.at(gesture.start + n.start)
degrees.append(dict(span.roles.degrees).get(n.pitch.number() % 12, ()) if span else ())
result = generator.solo.types.RealizedMotif(gesture.id, reference.id, actual, gesture.end - gesture.start, gesture.relationship, gesture.treatment, tuple(degrees), gesture.start)
return True, fidelity, result
def interval_steps(reference, harmony, source_start=None):
result = []
source_start = getattr(reference, 'source_start', source_start or fractions.Fraction())
for first, second in zip(reference.notes, reference.notes[1:]):
span = harmony.at((source_start or fractions.Fraction()) + first.start)
try:
step = generator.solo.types.scale_index(span.scale, second.pitch.number()) - generator.solo.types.scale_index(span.scale, first.pitch.number()) if span and span.scale else None
except ValueError:
step = None
result.append((step, second.pitch.number() - first.pitch.number()))
return tuple(result)
+118 -48
View File
@@ -1,10 +1,12 @@
from __future__ import annotations
import dataclasses
import fractions
import generator.harmony
import generator.model
import generator.scales.selection
import generator.solo.harmonic
import generator.solo.analysis
import generator.solo.policy
import generator.solo.sequences
@@ -91,67 +93,135 @@ def activity_passages(start, end, release, activity, shape=0):
arrival = i == len(spans) - 1
rate = arrival_rate if arrival else fractions.Fraction(str(min(2, float(desired) * weights[min(i, 2)] / max(denominator, 0.001)))).limit_denominator(1000)
occupancy = generator.solo.policy.DEFAULT.arrival_occupancy if arrival else generator.solo.policy.DEFAULT.active_occupancy
result.append(generator.solo.types.Passage(a, b, 'sustained_arrival' if arrival else 'active', rate, fractions.Fraction(str(occupancy))))
result.append(generator.solo.types.ActivitySpan(a, b, 'sustained_arrival' if arrival else 'active', rate, fractions.Fraction(str(occupancy))))
if release:
result.append(generator.solo.types.Passage(music_end, end, 'space', fractions.Fraction(), fractions.Fraction()))
result.append(generator.solo.types.ActivitySpan(music_end, end, 'space', fractions.Fraction(), fractions.Fraction()))
return tuple(result)
def decision(seed, name):
return int(generator.solo.policy.stable_rank(seed, name)[:8], 16)
def same_harmony(left, right):
return bool(left and right and left.span.chord and right.span.chord and generator.harmony.symbol(left.span.chord) == generator.harmony.symbol(right.span.chord))
def resolutions(choices):
distinct = []
for choice in choices:
if not distinct or not same_harmony(distinct[-1], choice):
distinct.append(choice)
return tuple(right for left, right in zip(distinct, distinct[1:]) if left.span.chord and right.span.chord and generator.scales.selection.dominant(left.span.chord) and (generator.scales.selection.root_number(left.span.chord) - generator.scales.selection.root_number(right.span.chord)) % 12 == 7)
def anchor_backbones(identifier, start, end, release, register, choices, bar, seed):
active = [c for c in choices if c.scale and c.span.start < end - release and c.span.end > start]
if not active:
return tuple([()])
finish = min(end - release, active[-1].span.end)
terminal_duration = min(fractions.Fraction(2), (finish - start) / 2)
terminal_start = max(start, active[-1].span.start, finish - terminal_duration)
terminal_duration = finish - terminal_start
opening = max(start, active[0].span.start)
moments = [(opening, 'opening')]
meaningful = list(resolutions(active)) + [b for a, b in zip(active, active[1:]) if a.scale.pitches != b.scale.pitches]
for choice in sorted(meaningful, key=lambda c: c.span.start):
time = choice.span.start
if time >= moments[-1][0] + bar * fractions.Fraction(3, 2) and time + bar < terminal_start and len(moments) < 4:
moments.append((time, 'intermediate'))
if terminal_start <= opening + fractions.Fraction(1, 2):
moments = []
moments.append((terminal_start, 'terminal'))
states = [(0.0, ())]
for index, (time, role) in enumerate(moments):
choice = next(c for c in active if c.span.start <= time < c.span.end)
chord_roles = generator.solo.harmonic.roles(choice.span.chord)
eligible = (chord_roles.basic | chord_roles.specified) & choice.scale.pitches
duration = terminal_duration if role == 'terminal' else fractions.Fraction(1, 2)
latest = time if role == 'terminal' else min(time + fractions.Fraction(3, 2), choice.span.end - duration, terminal_start - duration)
pitches = [generator.solo.types.scale_pitch(choice.scale, degree) for degree in range(-21, 28)]
pitches = [p for p in pitches if register[0] <= p.number() <= register[1] and p.number() % 12 in eligible]
desired = register[0] + (5 if index == 0 else 8 if role == 'terminal' else 11)
following = []
for cost, anchors in states:
for pitch in pitches:
jump = abs(pitch.number() - anchors[-1].pitch.number()) if anchors else 0
direction = 1 if role != 'terminal' else -1
wrong_way = bool(anchors and (pitch.number() - anchors[-1].pitch.number()) * direction < 0)
preferred = target_pitches(choice.span.chord, role == 'terminal')
score = cost + jump / 12 + 0.3 * wrong_way + abs(pitch.number() - desired) / 18 + 0.3 * (pitch.number() % 12 not in chord_roles.basic) + 0.2 * (role == 'terminal' and pitch.number() % 12 not in preferred)
anchor = generator.solo.types.Anchor(f'{identifier}_anchor_{index + 1}', time, latest, pitch, frozenset(eligible), register, duration, role, generator.harmony.symbol(choice.span.chord), False, finish if role == 'terminal' else None)
following.append((score, (*anchors, anchor)))
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], generator.solo.policy.stable_rank(seed, tuple(a.pitch for a in item[1]))), lambda item: (item[1][0].pitch.number(), item[1][-1].pitch.number()), generator.solo.policy.DEFAULT.alternatives))
return tuple(anchors for _, anchors in states)
def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choices, end: fractions.Fraction, part: str, sequences: generator.solo.sequences.Library) -> tuple[generator.solo.types.PhrasePlan, ...]:
boundaries = [fractions.Fraction()]
for measure in measures:
boundaries.append(min(end, boundaries[-1] + generator.model.fraction(measure['duration'])))
head, contrast, invented = choose_motifs(analysis, sequences, boundaries[1])
seed = generator.solo.policy.stable_rank(generator.solo.analysis.identity(analysis, measures, part), generator.solo.policy.describe()['fingerprint'])
corridor = (55, 74) if part == 'acoustic' else (59, 79)
invented = new_motif(sequences, boundaries[1], choices=choices, register=corridor, seed=seed)
alternate = new_motif(sequences, boundaries[1], choices=choices, register=corridor, seed=seed, variant=1)
result, index = [], 0
head, contrast, _ = choose_motifs(analysis, sequences, boundaries[1])
seed = generator.solo.policy.stable_rank(generator.solo.analysis.identity(analysis, measures, part), generator.solo.policy.SEED_NAMESPACE)
activity = fractions.Fraction(str(generator.solo.policy.DEFAULT.acoustic_activity if part == 'acoustic' else generator.solo.policy.DEFAULT.electric_activity))
result, index, passage_index, inventions = [], 0, 0, {}
cadences = resolutions(choices)
while index < len(measures) and boundaries[index] < end:
start = boundaries[index]
possible = list(range(min(index + 2, len(measures)), min(index + 4, len(measures)) + 1))
stops = sorted({min(index + size, len(measures)) for size in (4, 6, 8)})
def boundary_rank(stop):
time = boundaries[stop]
before = next((c for c in reversed(choices) if c.span.start < time), None)
after = next((c for c in choices if c.span.start >= time and c.span.chord), None)
resolution = before and after and before.span.chord and generator.scales.selection.dominant(before.span.chord) and (generator.scales.selection.root_number(before.span.chord) - generator.scales.selection.root_number(after.span.chord)) % 12 == 7
source_index = min(stop + int(len(analysis.boundaries) > len(measures) + 1), len(analysis.boundaries) - 1)
source_time = analysis.boundaries[source_index]
preceding = [n for n in analysis.melody if n.start < source_time]
breathing = bool(preceding) and preceding[-1].end <= source_time and (preceding[-1].end < source_time or preceding[-1].end - preceding[-1].start >= 2)
return not resolution, not breathing, abs(stop - index - 3), stop
def destination_rank(stop):
finish = boundaries[stop]
last = next((c for c in reversed(choices) if c.scale and c.span.start < finish and c.span.end > start), None)
resolved = bool(last and any(start < c.span.start < finish and same_harmony(c, last) for c in cadences))
dominant = bool(last and generator.scales.selection.dominant(last.span.chord))
return (0 if resolved else 2 if dominant else 1), abs(stop - index - 4), finish
stop = min(possible, key=boundary_rank)
stop = min(stops, key=destination_rank)
finish = boundaries[stop]
final = finish == end
phase = (start + finish) / (2 * end)
phrase_seed = generator.solo.policy.stable_rank(seed, (str(start), str(finish)))
choice = int(phrase_seed[:8], 16)
relationship = 'statement' if not result else 'repeat' if len(result) == 1 else 'answer' if len(result) % 2 else 'variation'
if part == 'acoustic':
motif = head if len(result) < 2 else (head, contrast, invented)[choice % 3]
else:
motif = invented if len(result) < 2 else (invented, alternate, head)[choice % 3]
treatment = 'opening_reference' if not result and motif.origin == 'melody' else 'invented' if motif.origin != 'melody' else 'answer' if relationship == 'answer' else ('rhythmic_reference', 'contour_reference')[choice % 2]
if finish <= start:
break
passage_index += 1
passage_id = f'{part}_passage_{passage_index}'
passage_seed = generator.solo.policy.stable_rank(seed, (str(start), str(finish)))
low, high = (55, 74) if part == 'acoustic' else (59, 79)
# Reserve the upper register for a prepared peak, not every active passage.
phase = (start + finish) / (2 * end)
register = (low + (5 if fractions.Fraction(1, 2) <= phase < fractions.Fraction(3, 4) else 0), high if phase >= fractions.Fraction(1, 2) else high - 4)
activity = fractions.Fraction(str(generator.solo.policy.DEFAULT.acoustic_activity if part == 'acoustic' else generator.solo.policy.DEFAULT.electric_activity))
ending = ('connected', 'sustain', 'breath')[choice % 3]
release = fractions.Fraction(1, 2) if choice % 2 else fractions.Fraction(1)
release = min(release, (finish - start) / 4) if ending == 'breath' else fractions.Fraction()
if treatment == 'rhythmic_reference' and motif.duration > finish - start - release:
treatment = 'contour_reference'
closure = 'closed' if final or relationship == 'answer' else 'open'
passages = activity_passages(start, finish, release, activity, choice % 3)
sounding = [p for p in passages if p.intent != 'space']
peak = sounding[min(1, len(sounding) - 1)]
ascending = choice % 2 == 0
landmarks = (
generator.solo.types.Landmark(start, min(start + 1, peak.start), register[0] + (3 if ascending else 8), register[0] + (7 if ascending else 12), 0, 'entrance'),
generator.solo.types.Landmark(peak.start, peak.end, register[1] - 6 if ascending else register[0] + 2, register[1] - 2 if ascending else register[0] + 6, 1 if ascending else -1, 'peak' if ascending else 'low point'),
generator.solo.types.Landmark(sounding[-1].start, sounding[-1].end, register[0] + (4 if ascending else 8), register[0] + (9 if ascending else 13), -1 if ascending else 1, 'arrival'),
)
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', start, finish, motif, relationship, treatment, destination(choices, start, finish, final, release, closure), register, activity, release, passages, landmarks, ending, closure, phrase_seed))
bar = boundaries[index + 1] - start
release = fractions.Fraction(1, 2) if decision(passage_seed, 'breathing') % 3 == 0 else fractions.Fraction()
release = min(release, (finish - start) / 8)
envelope = activity_passages(start, finish, release, activity, decision(passage_seed, 'activity') % 3)
anchors = anchor_backbones(passage_id, start, finish, release, register, choices, bar, passage_seed)
# Prefer four-bar phrasing; extend when a sustained harmony crosses that seam.
middle = min(index + 4, stop)
before = next((c for c in reversed(choices) if c.span.start < boundaries[middle]), None)
after = next((c for c in choices if c.span.start <= boundaries[middle] < c.span.end), None)
extended = stop - index > 4 and same_harmony(before, after)
phrase_stops = [stop] if extended or middle == stop else [middle, stop]
local = tuple(c for c in choices if c.span.start < boundaries[phrase_stops[0]] and c.span.end > start)
slot = decision(passage_seed, 'motif') % 3
if part == 'acoustic':
slot = 0 if passage_index <= 2 else slot
if slot == 2 and 0 not in inventions:
inventions[0] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed)
motif = head if slot == 0 else contrast if slot == 1 else inventions[0]
else:
slot = 0 if passage_index == 1 else slot
if slot < 2 and slot not in inventions:
inventions[slot] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed, variant=slot)
motif = head if slot == 2 else inventions[slot]
gestures, cursor = [], start
while cursor < finish - release:
gesture_end = min(cursor + bar, finish - release)
closing = gesture_end == finish - release
changed = any(c.span.start == cursor and c.scale and prior.scale and c.scale.pitches != prior.scale.pitches for prior, c in zip(choices, choices[1:]))
relationship = 'statement' if not gestures else 'close' if closing else 'repeat_with_harmonic_adaptation' if changed else 'answer' if cursor >= start + (finish - start) / 2 else 'extend'
treatment = 'opening_reference' if not gestures and motif.origin == 'melody' else 'invented' if not gestures else 'rhythmic_reference' if relationship == 'repeat_with_harmonic_adaptation' else 'answer' if relationship in ('answer', 'close') else 'contour_reference'
gestures.append(generator.solo.types.Gesture(f'{passage_id}_gesture_{len(gestures) + 1}', cursor, gesture_end, relationship, treatment))
cursor = gesture_end
phrase_start = start
for phrase_stop in phrase_stops:
phrase_end = boundaries[phrase_stop]
terminal = phrase_end == finish
treatment = 'opening_reference' if motif.origin == 'melody' else 'invented'
local_gestures = tuple(g for g in gestures if g.start < phrase_end and g.end > phrase_start)
landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, min(v[i].pitch.number() for v in anchors), max(v[i].pitch.number() for v in anchors), 0, a.role) for i, a in enumerate(anchors[0])) if anchors and anchors[0] else ()
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', phrase_start, phrase_end, motif, 'statement' if phrase_start == start else 'continue', treatment, None, register, activity, release if terminal else fractions.Fraction(), envelope, landmarks, 'breath' if terminal and release else 'sustain' if terminal else 'connected', 'closed' if terminal else 'open', passage_seed, passage_id, start, finish, anchors, local_gestures))
phrase_start = phrase_end
index = stop
return tuple(result)
+10 -2
View File
@@ -11,18 +11,26 @@ class Policy:
electric_activity: float = 1.375
active_occupancy: float = 0.75
arrival_occupancy: float = 0.9
weights: tuple[tuple[str, float], ...] = (('activity', 4), ('occupancy', 4), ('fidelity', 3), ('landmarks', 3), ('register', 1), ('contour', 1), ('connection', 1), ('repetition', 1), ('movement', 0.5))
weights: tuple[tuple[str, float], ...] = (('activity', 4), ('occupancy', 4), ('fidelity', 3), ('landmarks', 3), ('register', 1), ('contour', 1), ('connection', 1), ('repetition', 1), ('movement', 0.5), ('harmonic_exposure', 2))
beam_width: int = 64
extensions: int = 32
retrieval: int = 32
alternatives: int = 4
long_note: float = 2.0
stopping_note: float = 1.0
suspension_preparation: float = 0.5
suspension_resolution: float = 1.0
replan_attempts: int = 2
# Preserve the former default root seeds while decoupling them from policy edits.
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
DEFAULT = Policy()
def describe():
values = dataclasses.asdict(DEFAULT)
identity = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()
return {'fingerprint': identity, 'parameters': values}
return {'fingerprint': identity, 'seed_namespace': SEED_NAMESPACE, 'parameters': values}
def stable_rank(seed, value):
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
+306 -174
View File
@@ -3,12 +3,13 @@ from __future__ import annotations
import dataclasses
import fractions
import functools
import collections
import generator.fretboard
import generator.model
import generator.rhythm
import generator.scales.selection
import generator.solo.harmonic
import generator.solo.motifs
import generator.solo.placement
import generator.solo.planning
import generator.solo.scoring
@@ -28,6 +29,7 @@ class Cell:
end: fractions.Fraction
patterns: tuple[tuple[str, tuple[tuple[fractions.Fraction, fractions.Fraction], ...]], ...]
motif: generator.solo.types.Motif | None = None
gesture: generator.solo.types.Gesture | None = None
@dataclasses.dataclass(frozen=True)
class Candidate:
@@ -36,6 +38,7 @@ class Candidate:
treatment: str
rank: tuple
fidelity: float = 0
treatments: tuple[generator.solo.types.HarmonicTreatment, ...] = ()
def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fractions.Fraction, activity: fractions.Fraction, offset=fractions.Fraction()):
result = {}
@@ -66,7 +69,7 @@ def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fract
selected = generator.solo.sequences.diverse(result.items(), rank, family, 12)
return tuple((identifier, pattern) for pattern, identifier in selected)
def cells(plan, measures, rhythms):
def cells(plan, measures, rhythms, choices=()):
result, boundaries = [], [fractions.Fraction()]
for measure in measures:
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
@@ -82,7 +85,9 @@ def cells(plan, measures, rhythms):
while start < music_end:
index = next(i for i in range(len(measures)) if boundaries[i] <= start < boundaries[i + 1])
passage = next((p for p in plan.passages if p.start <= start < p.end), None)
end = min(music_end, boundaries[index + 1], passage.end if passage else music_end)
gesture = next((g for g in plan.gestures if g.start <= start < g.end), None)
chord_end = next((c.span.end for c in choices if c.span.start <= start < c.span.end), music_end)
end = min(music_end, boundaries[index + 1], passage.end if passage else music_end, gesture.end if gesture else music_end, chord_end)
activity = passage.activity if passage else plan.activity
patterns = rhythm_patterns(rhythms, measures[index]['time_signature'], end - start, activity, start - boundaries[index])
patterns += (('hold', ((fractions.Fraction(), end - start),)), ('space', ()))
@@ -90,7 +95,7 @@ def cells(plan, measures, rhythms):
onset = max(start, plan.target.earliest) - start
patterns = (('arrival_and_release', ((onset, end - start),)), *patterns)
motif = plan.motif if not result else None
result.append(Cell(start, end, patterns, motif))
result.append(Cell(start, end, patterns, motif, gesture))
start = end
if plan.release:
result.append(Cell(music_end, plan.end, (('planned_release', ()),)))
@@ -111,27 +116,6 @@ def options(choices, start, end, register):
def target_ok(notes, target):
return target is None or any(target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches for n in notes)
def thematic_paths(cell, spans, choices, plan):
states = [(0.0, ())]
for index, (start, end) in enumerate(spans):
following = []
available = options(choices, start, end, plan.register)
for _, path in states:
for pitch in available:
reference = cell.motif.notes[min(len(cell.motif.notes) - 1, index * len(cell.motif.notes) // max(1, len(spans)))]
note = generator.solo.types.Note(start, end, pitch, reference.sources, 'motif')
notes = (*path, note)
cost = generator.solo.scoring.motif_fidelity(notes, cell.motif, plan.treatment)
direction = plan.landmarks[1].direction if len(plan.landmarks) > 1 else 0
motion = notes[-1].pitch.number() - notes[0].pitch.number()
cost += 0.1 * (direction and motion * direction < 0)
cost += sum(generator.solo.scoring.corridor(n.pitch.number(), *plan.register) for n in notes) / (12 * len(notes))
following.append((cost, notes))
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], tuple(n.pitch for n in item[1])), lambda item: (item[1][-1].pitch.number(), generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(item[1], item[1][1:])))), BEAM_WIDTH))
if not states:
break
return [(generator.solo.scoring.motif_fidelity(notes, cell.motif, plan.treatment), notes) for _, notes in states]
def adapt_path(pitches, spans, choices, register):
"""Connect actual pitches across collections without carrying a stale degree index."""
states = [(0, ())]
@@ -145,112 +129,237 @@ def adapt_path(pitches, spans, choices, register):
states = sorted(following, key=lambda item: (item[0], tuple(n.pitch for n in item[1])))[:8]
return [notes for _, notes in states]
def sequence_paths(spans, choices, plan, sequences):
if not spans:
return [((), 'space')]
first_scale = next((c.scale for c in choices if c.span.start <= spans[0][0] < c.span.end), None)
if first_scale is None:
return []
allowed = tuple(generator.solo.types.permitted(choices, start, end) for start, end in spans)
if not all(allowed):
return []
available = options(choices, *spans[0], plan.register)
if len(spans) == 1:
return [((generator.solo.types.Note(*spans[0], pitch, role='path'),), 'single') for pitch in available]
landmark = next((p for p in plan.landmarks if p.latest >= spans[0][0]), None)
center = (landmark.low + landmark.high) // 2 if landmark else sum(plan.register) // 2
direction = landmark.direction if landmark else 0
starts = sorted(available, key=lambda p: (generator.solo.scoring.corridor(p.number(), *plan.register), abs(p.number() - center), p))[:8]
paths, identities = [], set()
for first in starts:
degree = next(i for i in range(-21, 28) if generator.solo.types.scale_pitch(first_scale, i) == first)
destination = center
if plan.target and spans[-1][0] >= plan.target.earliest:
compatible = [p for p in options(choices, *spans[-1], plan.register) if p.number() % 12 in plan.target.pitches]
if compatible:
destination = min(compatible, key=lambda p: (abs(p.number() - center), p)).number()
retrieved = sequences.retrieve(len(spans), round((destination - first.number()) / 2), direction)
routes = [(p.offsets(), 'sequences ' + ' + '.join(p.sources)) for p in retrieved]
routes.append(((0,) * len(spans), 'repeated_note'))
for offsets, identifier in routes:
pitches = tuple(generator.solo.types.scale_pitch(first_scale, degree + step) for step in offsets)
if not all(max(41, plan.register[0] - 7) <= p.number() <= min(84, plan.register[1] + 5) for p in pitches):
continue
if all(p.number() % 12 in classes for p, classes in zip(pitches, allowed)):
realized = [tuple(generator.solo.types.Note(start, end, pitch, role='path') for (start, end), pitch in zip(spans, pitches))]
elif any(classes != allowed[0] for classes in allowed):
realized = adapt_path(pitches, spans, choices, plan.register)
identifier = 'harmonic_adaptation of ' + identifier
else:
continue
for notes in realized:
identity = tuple(n.pitch for n in notes)
if identity not in identities:
identities.add(identity)
paths.append((notes, identifier))
return paths
def candidates(cell, plan, choices, sequences):
def anchor_treatments(anchors, harmony):
result = []
target = plan.target if plan.target and cell.start <= plan.target.latest < cell.end else None
for anchor in anchors:
span = harmony.at(anchor.earliest)
if span and anchor.pitch.number() % 12 in span.roles.specified:
result.append(generator.solo.types.HarmonicTreatment(anchor.id + '_color', 'color', anchor.pitch.number(), anchor.earliest, anchor.hold_until or anchor.latest + anchor.duration))
return tuple(result)
def rhythm_variants(cell, plan, state, anchors, reference):
patterns = cell.patterns
gesture = cell.gesture
if cell.motif and plan.treatment in ('quote', 'exact_quote'):
return [(name, tuple((cell.start + a, cell.start + b) for a, b in pattern), {}, False) for name, pattern in patterns]
if gesture and gesture.treatment == 'rhythmic_reference' and state.motifs:
# The identity is the rhythm actually heard, including its rests. Cropping
# at a search boundary does not create a new rhythmic attack.
pattern = tuple((max(cell.start, gesture.start + n.start) - cell.start, min(cell.end, gesture.start + n.end) - cell.start) for n in reference.notes if gesture.start + n.start < cell.end and gesture.start + n.end > cell.start)
patterns = ((reference.id + ':rhythm', pattern),)
due = [a for a in anchors if cell.start <= a.latest and a.earliest < cell.end and not generator.solo.harmonic.anchor_note(a, state.notes)]
holding = next((a for a in anchors if state.last and state.last.role != 'quote' and a.hold_until and a.hold_until > cell.start and generator.solo.harmonic.anchor_note(a, state.notes, until=cell.start)), None)
if holding:
stop = min(cell.end, holding.hold_until)
return [('anchor_sustain', ((cell.start, stop),), {cell.start: holding.pitch}, True)]
result = []
for name, pattern in patterns:
spans = tuple((cell.start + a, cell.start + b) for a, b in pattern)
variants = [(spans, {})]
for anchor in due:
following = []
for current, forced in variants:
onsets = [a for a, b in current if anchor.earliest <= a <= anchor.latest and b - a >= min(anchor.duration, cell.end - a)]
if gesture and gesture.treatment == 'rhythmic_reference':
times = onsets[:2]
else:
times = list(dict.fromkeys(onsets[:2] + [max(cell.start, anchor.earliest)]))
for time in times:
stop = min(cell.end, anchor.hold_until or time + anchor.duration)
original_end = next((b for a, b in current if a == time), stop)
stop = max(stop, original_end)
if gesture and gesture.treatment == 'rhythmic_reference' and stop != original_end:
continue
overlaid = [(a, min(b, time)) for a, b in current if a < time]
overlaid += [(time, stop)] + [(a, b) for a, b in current if a >= stop]
following.append((tuple((a, b) for a, b in overlaid if b > a), {**forced, time: anchor.pitch}))
variants = following
result.extend((name, spans, forced, False) for spans, forced in variants)
return result
def realize_route(spans, first, steps, forced, choices, plan, harmony, contracts, reference):
"""Instantiate relative motion at actual pitches, adapting only at collection changes."""
path = []
for index, (start, end) in enumerate(spans):
available = options(choices, start, end, plan.register)
if not available:
return None
if start in forced:
available = tuple(p for p in available if p.number() == forced[start].number())
if end - start >= generator.solo.policy.DEFAULT.long_note:
members = harmony.members(start, end)
available = tuple(p for p in available if p.number() % 12 in members or any(t.pitch == p.number() and t.start <= start and t.end >= end for t in contracts))
if not available:
return None
intended = first.number()
if path:
prior = path[-1].pitch.number()
scale = harmony.at(start).scale
step, semitones = steps[(index - 1) % len(steps)] if steps else (0, 0)
try:
intended = generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, prior) + step).number() if step is not None else prior + semitones
except ValueError:
# The old pitch is not in the new collection: preserve its sounding
# coordinate and adapt its intended motion, never transfer degree IDs.
intended = prior + semitones
stopping = end - start >= generator.solo.policy.DEFAULT.stopping_note
basic = harmony.at(start).roles.basic
pitch = min(available, key=lambda p: (abs(p.number() - intended) + (0.6 if stopping and p.number() % 12 not in basic else 0), p))
source = reference.notes[min(index, len(reference.notes) - 1)].sources if reference.notes else ()
path.append(generator.solo.types.Note(start, end, pitch, source, 'motif'))
return tuple(path)
def candidates(cell, plan, choices, sequences, state=None, anchors=(), harmony=None):
state = state or generator.solo.types.ContinuationState(plan.passage_id)
harmony = harmony or generator.solo.harmonic.Harmony(choices)
reference = generator.solo.motifs.reference(plan, cell.gesture, state.motifs)
treatment = cell.gesture.treatment if cell.gesture else plan.treatment if cell.motif else 'continue'
contracts = tuple(dict.fromkeys((*state.treatments, *anchor_treatments(anchors, harmony))))
protected = cell.motif and plan.treatment in ('quote', 'exact_quote')
local_plan = dataclasses.replace(plan, start=cell.start, end=cell.end, release=fractions.Fraction(), passages=tuple(dataclasses.replace(p, start=max(cell.start, p.start), end=min(cell.end, p.end)) for p in plan.passages if p.start < cell.end and p.end > cell.start))
evaluation = generator.solo.scoring.Evaluation(local_plan, choices)
for identifier, pattern in cell.patterns:
spans = tuple((cell.start + start, cell.start + end) for start, end in pattern)
active = tuple(i for i, (start, end) in enumerate(spans) if not any(c.scale is None and c.span.start < end and c.span.end > start for c in choices))
local_cell = cell
if len(active) != len(spans):
if cell.motif and plan.treatment in ('quote', 'exact_quote'):
result = []
for identifier, spans, forced, sustain in rhythm_variants(cell, plan, state, anchors, reference):
active = tuple((a, b) for a, b in spans if generator.solo.types.permitted(choices, a, b))
if active != spans:
if protected:
continue
spans = tuple(spans[i] for i in active)
spans = active
identifier += ':harmonic_fragment'
if cell.motif:
references = tuple(cell.motif.notes[min(len(cell.motif.notes) - 1, i * len(cell.motif.notes) // len(pattern))] for i in active)
local_cell = dataclasses.replace(cell, motif=dataclasses.replace(cell.motif, notes=references))
paths = []
if local_cell.motif and spans:
if plan.treatment in ('quote', 'exact_quote') and ':fragment' not in identifier and ':harmonic_fragment' not in identifier and ':displaced' not in identifier:
for octaves in (0, 1, -1, 2, -2):
notes = tuple(generator.solo.types.Note(start, end, dataclasses.replace(source.pitch, octave=source.pitch.octave + octaves), source.sources, 'quote') for (start, end), source in zip(spans, local_cell.motif.notes))
if valid_notes(notes, choices):
paths.append((notes, f'quote octave {octaves:+}', 0))
if plan.treatment not in ('quote', 'exact_quote'):
for mismatch, notes in thematic_paths(local_cell, spans, choices, plan):
treatment = 'fragment' if 'fragment' in identifier else plan.treatment
paths.append((notes, treatment, mismatch))
if not spans:
if not protected:
result.append(Candidate((), identifier, 'space', (generator.solo.scoring.value(evaluation.features(())),), treatments=contracts))
continue
available = options(choices, *spans[0], plan.register)
pending = next((t for t in state.treatments if t.kind == 'suspension' and t.deadline >= cell.start and state.last and state.last.pitch.number() == t.pitch and state.last.end == cell.start and not any(n.pitch.number() == t.resolution and n.start >= t.start for n in state.notes)), None)
if pending:
resolution = next((p for p in available if p.number() == pending.resolution), None)
if resolution is None or spans[0][0] != cell.start:
continue
forced = {**forced, spans[0][0]: resolution}
sustain = False
last = state.last
next_anchor = next((a for a in anchors if a.latest >= cell.start and not generator.solo.harmonic.anchor_note(a, state.notes)), None)
destination = next_anchor.pitch.number() if next_anchor else last.pitch.number() if last else reference.notes[0].pitch.number()
if protected:
paths = []
for octave in (0, 1, -1, 2, -2):
notes = tuple(generator.solo.types.Note(a, b, dataclasses.replace(n.pitch, octave=n.pitch.octave + octave), n.sources, 'quote') for (a, b), n in zip(spans, cell.motif.notes))
if valid_notes(notes, choices):
paths.append((notes, f'quote octave {octave:+}'))
elif sustain and last:
paths = [((generator.solo.types.Note(*spans[0], last.pitch, role='sustain', continuation=True),), 'anchor_sustain')]
else:
paths = [(notes, name, 0) for notes, name in sequence_paths(spans, choices, plan, sequences)]
for notes, treatment, identity in paths:
notes = tuple(dataclasses.replace(n, role='target' if n.role not in ('motif', 'quote') and target and target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches else n.role) for n in notes)
features = evaluation.features(notes)
fidelity = identity if cell.motif else 0
near = last.pitch.number() if last else destination
starts = sorted(available, key=lambda p: (abs(p.number() - near), p))[:3]
if spans[0][0] in forced:
starts = [forced[spans[0][0]]]
scale = harmony.at(spans[0][0]).scale
target = min(available, key=lambda p: abs(p.number() - destination))
paths = []
motif_steps = generator.solo.motifs.interval_steps(reference, harmony)
gesture_start = cell.gesture.start if cell.gesture else cell.start
heard = tuple(n for n in state.notes if n.start >= gesture_start)
cursor = len(heard)
if motif_steps and last:
step, semitones = motif_steps[max(0, cursor - 1) % len(motif_steps)]
try:
expected = generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, last.pitch.number()) + step).number() if step is not None else last.pitch.number() + semitones
except ValueError:
expected = last.pitch.number() + semitones
supported = min(available, key=lambda p: (abs(p.number() - expected), p))
if supported not in starts:
starts.append(supported)
if motif_steps:
motif_steps = motif_steps[cursor % len(motif_steps):] + motif_steps[:cursor % len(motif_steps)]
for first in starts:
displacement = generator.solo.types.scale_index(scale, target.number()) - generator.solo.types.scale_index(scale, first.number())
if next_anchor and next_anchor.earliest > cell.end:
displacement = round(displacement * float((cell.end - cell.start) / (next_anchor.earliest - cell.start)))
direction = (displacement > 0) - (displacement < 0)
routes = [(motif_steps, 'develop ' + reference.id)]
retrieved = sequences.retrieve(len(spans), displacement, direction)
selected = generator.solo.sequences.diverse(retrieved, lambda p: (abs(p.offsets()[-1] - displacement), p.sources), lambda p: generator.solo.sequences.family(p.steps), 4)
for path in selected:
steps = tuple((step, generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, first.number()) + step).number() - first.number()) for step in path.steps)
if motif_steps and treatment != 'rhythmic_reference' and not (cell.gesture and cell.gesture.relationship == 'close'):
# Preserve identifying motion, then let the authored vocabulary
# connect it toward the next anchor. The next cell uses this result.
keep = min(2, len(steps))
steps = tuple(motif_steps[i % len(motif_steps)] for i in range(keep)) + steps[keep:]
routes.append((steps, 'sequences ' + ' + '.join(path.sources) + '; develop ' + reference.id))
for steps, name in routes:
notes = realize_route(spans, first, steps, forced, choices, plan, harmony, contracts, reference)
if notes:
paths.append((notes, name))
if last and last.end == cell.start and spans[0][0] == cell.start and not protected:
# An existing sustain is a real alternative, independent of reattack.
for notes, name in list(paths):
if notes[0].pitch.number() == last.pitch.number() and last.role != 'quote':
paths.append(((dataclasses.replace(notes[0], pitch=last.pitch, continuation=True, role='sustain'), *notes[1:]), 'common_tone; ' + name))
# Plan a prepared suspension from the incoming sound before constructing
# its continuation. The contract survives subsequent search boundaries.
if last and last.role != 'quote' and last.end == cell.start and cell.end - cell.start >= 1 and last.start <= cell.start - fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_preparation)):
span = harmony.at(cell.start)
prepared = last.pitch.number() % 12 in harmony.members(cell.start - fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_preparation)), cell.start)
if prepared and span and span.scale and last.pitch.number() % 12 in span.scale.pitches and last.pitch.number() % 12 not in span.roles.basic:
resolution_time = cell.start + fractions.Fraction(1, 2)
resolving = [p for p in options(choices, resolution_time, min(cell.end, resolution_time + fractions.Fraction(1, 2)), plan.register) if p.number() % 12 in span.roles.basic and harmony.step(last.pitch.number(), p.number(), resolution_time)]
for pitch in resolving:
contract = generator.solo.types.HarmonicTreatment(f'{plan.passage_id}_suspension_{cell.start}_{pitch.number()}', 'suspension', last.pitch.number(), cell.start, resolution_time, pitch.number(), cell.start + fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_resolution)))
for original, name in paths[:4]:
tail = tuple(n for n in original if n.start > resolution_time)
end = tail[0].start if tail else cell.end
suspended = (generator.solo.types.Note(cell.start, resolution_time, last.pitch, role='sustain', continuation=True), generator.solo.types.Note(resolution_time, end, pitch, role='motif'), *tail)
raw = tuple(dataclasses.replace(n, position=None) for n in state.notes)
if not raw:
continue
combined = generator.solo.types.sounding_notes((*raw, *suspended))
checked = harmony.evaluate(combined, (*contracts, contract), until=cell.end)
if checked.valid:
features = evaluation.features(tuple(dataclasses.replace(n, continuation=False) for n in suspended))
features['harmonic_exposure'] = checked.exposure
fidelity = generator.solo.scoring.motif_fidelity(tuple(n for n in combined if n.start >= (cell.gesture.start if cell.gesture else cell.start)), reference, treatment)
features['fidelity'] = fidelity
result.append(Candidate(suspended, identifier + '; prepared_suspension; ' + name, treatment, (generator.solo.scoring.value(features),), fidelity, (*contracts, contract)))
for notes, name in paths:
# Continuations are canonicalized with their incoming sounding event.
raw = tuple(dataclasses.replace(n, position=None) for n in state.notes)
combined = generator.solo.types.sounding_notes((*raw, *notes)) if raw or not notes[0].continuation else ()
if not combined:
continue
harmonic = harmony.evaluate(combined, contracts, until=cell.end)
if not harmonic.valid:
continue
local = tuple(dataclasses.replace(n, continuation=False) for n in notes)
features = evaluation.features(local)
gesture_notes = tuple(n for n in combined if n.start >= (cell.gesture.start if cell.gesture else cell.start))
fidelity = (generator.solo.scoring.motif_fidelity(gesture_notes, reference, treatment) if len(gesture_notes) > 1 else 0.5) if cell.gesture or cell.motif else 0
features['fidelity'] = fidelity
rank = (generator.solo.scoring.value(features),)
result.append(Candidate(notes, identifier, treatment, rank, fidelity))
# A no-harmony passage is intentional silence, not a failed pitch search.
if not (cell.motif and plan.treatment in ('quote', 'exact_quote')) and not any(c.span.chord and c.span.start < cell.end and c.span.end > cell.start for c in choices):
result = [Candidate((), 'no_harmony', 'space', (0,))]
return sorted(result, key=lambda c: (c.rank, c.source, c.treatment, tuple(n.pitch for n in c.notes)))
features['harmonic_exposure'] = harmonic.exposure
features['connection'] = min(1, abs(notes[0].pitch.number() - last.pitch.number()) / 12) if last else 0
result.append(Candidate(notes, identifier + '; ' + name, name if protected else treatment, (generator.solo.scoring.value(features),), fidelity, contracts))
unique = {}
for candidate in sorted(result, key=lambda c: (c.rank, c.source, tuple(n.pitch for n in c.notes))):
unique.setdefault((candidate.notes, candidate.treatments), candidate)
return list(unique.values())
@dataclasses.dataclass
class SearchContext:
pools: dict = dataclasses.field(default_factory=dict)
results: dict = dataclasses.field(default_factory=dict)
evaluations: dict = dataclasses.field(default_factory=collections.OrderedDict)
diagnostics: dict = dataclasses.field(default_factory=lambda: {'searches': 0, 'cache_hits': 0, 'generated_candidates': 0, 'evaluated_extensions': 0, 'peak_states': 0, 'candidate_families': {}, 'surviving_families': {}})
def note_family(notes):
return generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))) if notes else 'space'
def state_identity(state, plan):
_, decisions, last, _, notes = state
ending = (last.pitch, last.position, last.start, last.end) if last else None
times = tuple((float(n.start), float(n.end)) for n in notes)
windows = tuple((float(p.start), float(p.end)) for p in generator.solo.scoring.passages(plan))
activity = tuple((sum(start <= a < end for a, _ in times), sum(max(0, min(b, end) - max(a, start)) for a, b in times)) for start, end in windows)
rhythm = tuple((n.start, n.end) for n in notes[-3:])
progress = tuple(any(p.earliest <= n.start <= p.latest and p.low <= n.pitch.number() <= p.high for n in notes) for p in plan.landmarks)
return ending, activity, rhythm, progress, tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes[-4:], notes[-4:][1:])), tuple(d.treatment for d in decisions)
@dataclasses.dataclass(frozen=True)
class BeamState:
score: float
decisions: tuple
music: generator.solo.types.ContinuationState
travel: int = 0
fidelity: tuple[float, ...] = ()
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
context = context if context is not None else SearchContext()
@@ -259,78 +368,101 @@ def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=No
context.diagnostics['cache_hits'] += 1
return context.results[key]
context.diagnostics['searches'] += 1
harmony = generator.solo.harmonic.Harmony(choices)
incoming = previous if isinstance(previous, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=previous)
continuing = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
initial = incoming if continuing else generator.solo.types.ContinuationState(plan.passage_id, last=incoming.last, treatments=plan.treatments)
backbones = plan.anchor_options or tuple([()])
# A caller explicitly scheduling quotation owns its rhythm and pitches. Such
# plans must provide compatible anchors; no automatic rewrite is permitted.
states = [BeamState(0, (), dataclasses.replace(initial, backbone=i), initial.travel, initial.fidelity) for i in ([initial.backbone] if continuing else range(len(backbones)))]
if plan not in context.pools:
context.pools[plan] = tuple((cell, candidates(cell, plan, choices, sequences)) for cell in cells(plan, measures, rhythms))
for _, pool in context.pools[plan]:
context.diagnostics['generated_candidates'] += len(pool)
for candidate in pool:
family = note_family(candidate.notes)
counts = context.diagnostics['candidate_families']
counts[family] = counts.get(family, 0) + 1
states = [(0.0, (), previous, 0, ())]
for cell, pool in context.pools[plan]:
evaluation = generator.solo.scoring.Evaluation(plan, choices, cell.end)
following, extensions = [], {}
for _, decisions, last, travel, sounded in states:
target_met = target_ok(sounded, plan.target)
def extension_rank(candidate):
jump = abs(candidate.notes[0].pitch.number() - last.pitch.number()) / 12 if candidate.notes and last else 0
return candidate.rank[0] + min(1, jump), candidate.source, candidate.treatment, tuple(n.pitch for n in candidate.notes)
extension_key = (last.pitch if last else None, target_met)
if extension_key not in extensions:
eligible = [c for c in pool if not plan.target or plan.target.latest >= cell.end and cell.end < plan.end or target_met or target_ok(c.notes, plan.target)]
extensions[extension_key] = generator.solo.sequences.diverse(eligible, extension_rank, lambda c: (len(c.notes), note_family(c.notes), c.treatment if cell.motif else ''), CELL_LIMIT)
for candidate in extensions[extension_key]:
variants = [candidate.notes]
passage = next((p for p in plan.passages if p.start <= cell.start < p.end), None)
if cell.motif is None and last and last.end == cell.start and len(candidate.notes) == 1 and candidate.notes[0].start == cell.start and candidate.notes[0].pitch == last.pitch and passage and passage.intent == 'sustained_arrival':
variants.append((dataclasses.replace(candidate.notes[0], continuation=True, role='sustain'),))
for variant in variants:
context.diagnostics['evaluated_extensions'] += 1
placed = generator.solo.placement.place(variant, tempo, last)
if placed is None:
context.pools[plan] = cells(plan, measures, rhythms, choices)
evaluation_plan = dataclasses.replace(plan, start=plan.passage_start if plan.passage_start is not None else plan.start, end=plan.passage_end or plan.end)
last_error = ''
for cell in context.pools[plan]:
evaluation = generator.solo.scoring.Evaluation(evaluation_plan, choices, cell.end)
following, pools = [], {}
for state in states:
music = state.music
anchors = backbones[music.backbone]
reference = generator.solo.motifs.reference(plan, cell.gesture, music.motifs)
# Concrete results depend on pitch, fingering, motif, anchor progress,
# rhythm history and unresolved treatments. Abstract rhythms alone cache
# across unrelated incoming lines.
pool_key = (music, anchors)
if pool_key not in pools:
pool = candidates(cell, plan, choices, sequences, music, anchors, harmony)
context.diagnostics['generated_candidates'] += len(pool)
for candidate in pool:
family = note_family(candidate.notes)
counts = context.diagnostics['candidate_families']
counts[family] = counts.get(family, 0) + 1
pools[pool_key] = generator.solo.sequences.diverse(pool, lambda c: (c.rank, c.source), lambda c: (len(c.notes), note_family(c.notes), bool(c.notes and c.notes[0].continuation)), CELL_LIMIT)
for candidate in pools[pool_key]:
context.diagnostics['evaluated_extensions'] += 1
placed = generator.solo.placement.place(candidate.notes, tempo, music.last)
if placed is None:
continue
comfort, notes = placed
canonical = generator.solo.types.sounding_notes((*music.notes, *notes))
completed = cell.end == (plan.passage_end or plan.end)
harmonic = harmony.evaluate(canonical, candidate.treatments, until=cell.end, complete=completed)
valid, error = generator.solo.harmonic.anchors_valid(anchors, canonical, cell.end, complete=completed, closure=plan.closure)
if not harmonic.valid or not valid:
last_error = harmonic.error or error
continue
if plan.target and (plan.target.latest < cell.end or cell.end == plan.end) and not target_ok(canonical, plan.target):
last_error = 'legacy destination was not reached'
continue
motifs, fidelity = music.motifs, state.fidelity
if cell.gesture and cell.end == cell.gesture.end:
valid, mismatch, identity = generator.solo.motifs.complete(cell.gesture, reference, canonical, harmony)
if not valid:
last_error = f'{cell.gesture.id}: insufficient evidence of {cell.gesture.treatment} from {reference.id}'
continue
comfort, notes = placed
decision = generator.solo.types.Decision(cell.start, cell.end, candidate.source, candidate.treatment, notes, candidate.fidelity)
combined = (*decisions, decision)
canonical = generator.solo.types.sounding_notes((*sounded, *notes))
if plan.target and (plan.target.latest < cell.end or cell.end == plan.end) and not target_ok(canonical, plan.target):
continue
musical_key = (plan, cell.end, tuple((n.start, n.end, n.pitch, n.role) for n in canonical))
if musical_key not in context.evaluations:
context.evaluations[musical_key] = evaluation.features(canonical) if evaluation.feasible(canonical) else None
if len(context.evaluations) > 32768:
context.evaluations.popitem(last=False)
else:
context.evaluations.move_to_end(musical_key)
evaluated = context.evaluations[musical_key]
if evaluated is None:
continue
features = dict(evaluated)
features['connection'] = min(1, abs(canonical[0].pitch.number() - previous.pitch.number()) / 12) if canonical and previous else 0
features['movement'] = min(1, (travel + comfort) / max(1, len(canonical)) / 2000)
total = generator.solo.scoring.value(features)
following.append((total, combined, canonical[-1] if canonical else last, travel + comfort, canonical))
fidelity += (mismatch,)
if identity:
motifs += (identity,)
if not evaluation.feasible(canonical):
last_error = 'planned activity, sustain or space not fulfilled'
continue
features = evaluation.features(canonical)
features['fidelity'] = (sum(fidelity) + candidate.fidelity) / (len(fidelity) + 1)
features['harmonic_exposure'] = harmonic.exposure
features['connection'] = min(1, abs(canonical[0].pitch.number() - incoming.last.pitch.number()) / 12) if canonical and incoming.last and not continuing else 0
features['movement'] = min(1, (state.travel + comfort) / max(1, len(canonical)) / 2000)
if cell.gesture:
recent = tuple(n for n in canonical if cell.gesture.start <= n.start < cell.gesture.end)
intended_repeats = sum(a.pitch == b.pitch == c.pitch and a.start == x.start - cell.gesture.start and b.start == y.start - cell.gesture.start and c.start == z.start - cell.gesture.start for a, b, c in zip(reference.notes, reference.notes[1:], reference.notes[2:]) for x, y, z in zip(recent, recent[1:], recent[2:]) if x.pitch == y.pitch == z.pitch)
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(canonical) - 2))
total = generator.solo.scoring.value(features)
last = canonical[-1] if canonical else music.last
progress = tuple(a.id for a in anchors if generator.solo.harmonic.anchor_note(a, canonical))
direction = (last.pitch.number() > music.last.pitch.number()) - (last.pitch.number() < music.last.pitch.number()) if last and music.last else music.direction
carried = generator.solo.types.ContinuationState(plan.passage_id, canonical, motifs, candidate.treatments, progress, direction, last, music.backbone, fidelity, state.travel + comfort)
decision = generator.solo.types.Decision(cell.start, cell.end, candidate.source, candidate.treatment, notes, candidate.fidelity, cell.gesture.id if cell.gesture else '', reference.id)
following.append(BeamState(total, (*state.decisions, decision), carried, state.travel + comfort, fidelity))
if not following:
raise SearchExhausted(f'{plan.id}, beats {cell.start + 1}-{cell.end + 1}: bounded solo search exhausted; no feasible phrase extension among {BEAM_WIDTH} states / {CELL_LIMIT} extensions (not proof that the passage is unplayable)')
raise SearchExhausted(f'{plan.id}, beats {cell.start + 1}-{cell.end + 1}: bounded solo search exhausted; {last_error or "no eligible continuation"}; {BEAM_WIDTH} states / {CELL_LIMIT} extensions (not proof that the passage is unplayable)')
def rank(state):
return state[0], tuple((n.pitch, n.position, n.start, n.end) for n in state[4])
return state.score, tuple((n.pitch, n.position, n.start, n.end) for n in state.music.notes)
unique = {}
for state in sorted(following, key=rank):
unique.setdefault(state_identity(state, plan), state)
states = list(generator.solo.sequences.diverse(unique.values(), rank, lambda s: (note_family(s[4]), len(s[4]), s[4][-1].pitch.number() if s[4] else None), BEAM_WIDTH))
states.sort(key=rank)
unique.setdefault(state.music, state)
# A rolling one-gesture window keeps the longer plan bounded. Relevant
# motif/anchor/treatment state survives; a matching last fret is not identity.
limit = generator.solo.policy.DEFAULT.alternatives if cell.gesture and cell.end == cell.gesture.end else BEAM_WIDTH
states = list(generator.solo.sequences.diverse(unique.values(), rank, lambda s: (s.music.backbone, note_family(s.music.notes[-4:]), s.music.last.pitch if s.music.last else None), limit))
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
selected = generator.solo.sequences.diverse(states, lambda s: (s[0], tuple(n.pitch for n in s[4])), lambda s: (note_family(s[4]), len(s[4]), s[4][-1].pitch if s[4] else None), generator.solo.policy.DEFAULT.alternatives)
selected = generator.solo.sequences.diverse(states, lambda s: (s.score, tuple(n.pitch for n in s.music.notes)), lambda s: (s.music.backbone, note_family(s.music.notes[-4:])), generator.solo.policy.DEFAULT.alternatives)
for state in selected:
family = note_family(state[4])
family = note_family(state.music.notes[-4:])
counts = context.diagnostics['surviving_families']
counts[family] = counts.get(family, 0) + 1
result = tuple(generator.solo.types.Phrase(plan, decisions, score) for score, decisions, _, _, _ in selected)
result = tuple(generator.solo.types.Phrase(plan, state.decisions, state.score, state.music, backbones[state.music.backbone]) for state in selected)
context.results[key] = result
return result
+47 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import dataclasses
import json
import fractions
import generator.harmony
import generator.model
@@ -9,6 +10,7 @@ import generator.scales.report
import generator.scales.selection
import generator.scales.timeline
import generator.solo.types
import generator.solo.harmonic
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str:
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
@@ -16,16 +18,29 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
lines = [generator.scales.report.passage(solo.part, region, positioned, scales), f'Phrase composition: {solo.part}', f'Placement reference tempo: {solo.tempo} quarter notes/minute. Runtime tempo changes are not a comfort guarantee.', 'Times below are quarter-note offsets from the start of this solo; pitches are sounding.']
if solo.diagnostics:
lines.append('Composition diagnostics: ' + json.dumps(solo.diagnostics, sort_keys=True))
detail = structure(solo)
for passage in detail['passages']:
lines.append(f"Melodic passage {passage['id']}: {passage['start']}-{passage['end']}; phrases {', '.join(passage['phrases'])}")
lines.append(f"Harmonic exposure: {detail['harmonic_exposure']:.4f}; unresolved treatments: {len(detail['unresolved_treatments'])}")
for phrase in solo.phrases:
plan = phrase.plan
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}')
for passage in plan.passages:
lines.append(f' Passage {passage.start}-{passage.end}: {passage.intent}; attacks/beat {float(passage.activity):.3f}; occupancy {float(passage.occupancy):.2f}')
lines.append(f' Activity {passage.start}-{passage.end}: {passage.intent}; attacks/beat {float(passage.activity):.3f}; occupancy {float(passage.occupancy):.2f}')
for landmark in plan.landmarks:
lines.append(f' Landmark {landmark.name}: {landmark.earliest}-{landmark.latest}; sounding MIDI {landmark.low}-{landmark.high}; direction {landmark.direction}')
if plan.target:
lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}')
for anchor in phrase.anchors:
actual = generator.solo.harmonic.anchor_note(anchor, phrase.state.notes if phrase.state else solo.notes())
lines.append(f' Anchor {anchor.id}: {anchor.role}; {anchor.chord}; {anchor.pitch.label()}; window {anchor.earliest}-{anchor.latest}; hold {anchor.duration}; hold until {anchor.hold_until}; completed {actual is not None}')
if phrase.state:
valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=phrase.plan.end == phrase.plan.passage_end, closure=phrase.plan.closure)
lines.append(f' Ending contract fulfilled: {valid}; {reason}')
for motif in phrase.state.motifs:
if phrase.plan.start <= motif.source_start < phrase.plan.end:
lines.append(f' Realized motif {motif.id}: parent {motif.parent}; {motif.relationship}; {motif.treatment}; pitches ' + ' '.join(n.pitch.label() for n in motif.notes) + f'; rhythm ' + ' '.join(f'{n.start}:{n.end}' for n in motif.notes))
for decision in phrase.decisions:
lines.append(f' {decision.start}-{decision.end}: {decision.source}; {decision.treatment}')
if not decision.notes:
@@ -36,4 +51,35 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12]
function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection'
lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}')
lines.append('\nSounding harmonic spans (ties merged before classification):')
for event in detail['harmonic_roles']:
if float(fractions.Fraction(event['sounding_duration'])) >= 1 or event['protected']:
lines.append(' ' + json.dumps(event, sort_keys=True))
return '\n'.join(lines) + '\n'
def structure(solo):
harmony = generator.solo.harmonic.Harmony(solo.choices)
contracts = tuple(dict.fromkeys(t for p in solo.phrases if p.state for t in p.state.treatments))
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True)
def rational(value):
if isinstance(value, dict):
return {key: rational(item) for key, item in value.items()}
if isinstance(value, (tuple, list, set, frozenset)):
return [rational(item) for item in value]
if isinstance(value, fractions.Fraction):
return str(value)
return value
phrases = []
for phrase in solo.phrases:
notes = phrase.state.notes if phrase.state else solo.notes()
complete = phrase.plan.end == (phrase.plan.passage_end or phrase.plan.end)
valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, notes, phrase.plan.end, complete=complete, closure=phrase.plan.closure)
anchors = []
for anchor in phrase.anchors:
event = generator.solo.harmonic.anchor_note(anchor, notes)
anchors.append({**rational(dataclasses.asdict(anchor)), 'pitch': anchor.pitch.label(), 'completed': event is not None, 'actual_start': str(event.start) if event else None, 'actual_end': str(event.end) if event else None})
motifs = tuple(m for m in phrase.state.motifs if phrase.plan.start <= m.source_start < phrase.plan.end) if phrase.state else ()
phrases.append({'id': phrase.plan.id, 'passage': phrase.plan.passage_id, 'start': str(phrase.plan.start), 'end': str(phrase.plan.end), 'ending': phrase.plan.ending, 'closure': phrase.plan.closure, 'ending_completed': valid, 'ending_error': reason, 'anchors': anchors, 'motif_lineage': rational([dataclasses.asdict(m) for m in motifs])})
return {'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'harmonic_roles': harmony.describe(solo.notes(), contracts), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
+8 -5
View File
@@ -10,8 +10,8 @@ def passages(plan):
if plan.passages:
return plan.passages
end = plan.end - plan.release
active = (generator.solo.types.Passage(plan.start, end, 'active', plan.activity, fractions.Fraction(3, 4)),) if end > plan.start else ()
return active + ((generator.solo.types.Passage(end, plan.end, 'space', fractions.Fraction(), fractions.Fraction()),) if plan.release else ())
active = (generator.solo.types.ActivitySpan(plan.start, end, 'active', plan.activity, fractions.Fraction(3, 4)),) if end > plan.start else ()
return active + ((generator.solo.types.ActivitySpan(end, plan.end, 'space', fractions.Fraction(), fractions.Fraction()),) if plan.release else ())
def intervals(choices, start, end):
return tuple((max(start, c.span.start), min(end, c.span.end)) for c in choices if c.scale and c.span.start < end and c.span.end > start)
@@ -23,12 +23,14 @@ def corridor(pitch, low, high):
return max(low - pitch, 0, pitch - high)
def motif_fidelity(notes, motif, treatment):
if treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
if treatment in ('quote', 'exact_quote'):
return 0.0
if treatment == 'rhythmic_reference':
return 1.0 if len(notes) < min(2, len(motif.notes)) else 0.0
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
expected = motif.contour()
if not actual or not expected:
return 0.0 if len(notes) >= min(1, len(motif.notes)) else 1.0
return 0.0 if len(notes) == len(motif.notes) == 1 else 1.0
count = min(2, len(actual), len(expected)) if treatment == 'opening_reference' else max(1, len(actual) // 2) if treatment == 'answer' else len(actual)
errors = []
for index in range(count):
@@ -36,7 +38,8 @@ def motif_fidelity(notes, motif, treatment):
motion = actual[index]
direction = ((reference > 0) - (reference < 0)) != ((motion > 0) - (motion < 0))
errors.append(0.75 * direction + 0.25 * min(1, abs(motion - reference) / 12))
return sum(errors) / len(errors)
evidence = min(2, len(expected))
return max(sum(errors) / len(errors), max(0, evidence - len(actual)) / evidence)
class Evaluation:
"""Prepare fixed harmonic windows once; only soft penalties use float time."""
+89 -2
View File
@@ -65,13 +65,80 @@ class TargetEvent:
reason: str
@dataclasses.dataclass(frozen=True)
class Passage:
class ActivitySpan:
start: fractions.Fraction
end: fractions.Fraction
intent: str
activity: fractions.Fraction
occupancy: fractions.Fraction
@dataclasses.dataclass(frozen=True)
class HarmonicTreatment:
id: str
kind: str
pitch: int
start: fractions.Fraction
end: fractions.Fraction
resolution: int | None = None
deadline: fractions.Fraction | None = None
preparation: fractions.Fraction = fractions.Fraction(1, 2)
@dataclasses.dataclass(frozen=True)
class Anchor:
id: str
earliest: fractions.Fraction
latest: fractions.Fraction
pitch: Pitch
eligible: frozenset[int]
register: tuple[int, int]
duration: fractions.Fraction
role: str
chord: str
onset_required: bool = False
hold_until: fractions.Fraction | None = None
@dataclasses.dataclass(frozen=True)
class Gesture:
id: str
start: fractions.Fraction
end: fractions.Fraction
relationship: str
treatment: str
@dataclasses.dataclass(frozen=True)
class RealizedMotif:
id: str
parent: str
notes: tuple[Note, ...]
duration: fractions.Fraction
relationship: str
treatment: str
chord_degrees: tuple[tuple[int, ...], ...] = ()
source_start: fractions.Fraction = fractions.Fraction()
def contour(self):
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
@dataclasses.dataclass(frozen=True)
class ContinuationState:
passage_id: str
notes: tuple[Note, ...] = ()
motifs: tuple[RealizedMotif, ...] = ()
treatments: tuple[HarmonicTreatment, ...] = ()
anchors: tuple[str, ...] = ()
direction: int = 0
last: Note | None = None
backbone: int = 0
fidelity: tuple[float, ...] = ()
travel: int = 0
@dataclasses.dataclass(frozen=True)
class MelodicPassagePlan:
id: str
start: fractions.Fraction
end: fractions.Fraction
phrase_ids: tuple[str, ...]
seed: str
@dataclasses.dataclass(frozen=True)
class Landmark:
earliest: fractions.Fraction
@@ -93,11 +160,17 @@ class PhrasePlan:
register: tuple[int, int]
activity: fractions.Fraction
release: fractions.Fraction = fractions.Fraction(1, 2)
passages: tuple[Passage, ...] = ()
passages: tuple[ActivitySpan, ...] = ()
landmarks: tuple[Landmark, ...] = ()
ending: str = 'breath'
closure: str = 'closed'
seed: str = ''
passage_id: str = ''
passage_start: fractions.Fraction | None = None
passage_end: fractions.Fraction | None = None
anchor_options: tuple[tuple[Anchor, ...], ...] = ()
gestures: tuple[Gesture, ...] = ()
treatments: tuple[HarmonicTreatment, ...] = ()
@dataclasses.dataclass(frozen=True)
class Decision:
@@ -107,12 +180,16 @@ class Decision:
treatment: str
notes: tuple[Note, ...]
fidelity: float = 0
gesture_id: str = ''
parent_motif: str = ''
@dataclasses.dataclass(frozen=True)
class Phrase:
plan: PhrasePlan
decisions: tuple[Decision, ...]
score: float = 0
state: ContinuationState | None = None
anchors: tuple[Anchor, ...] = ()
@dataclasses.dataclass(frozen=True)
class Solo:
@@ -122,6 +199,7 @@ class Solo:
tempo: fractions.Fraction
end: fractions.Fraction
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
passages: tuple[MelodicPassagePlan, ...] = ()
def notes(self) -> tuple[Note, ...]:
return sounding_notes(n for p in self.phrases for d in p.decisions for n in d.notes)
@@ -136,6 +214,15 @@ def scale_pitch(scale: generator.scales.library.Scale, index: int, octave: int =
alter = generator.model.alteration(name['alter'])
return Pitch(name['letter'], alter, int((number - generator.model.NATURAL[name['letter']] - alter) // 12 - 1))
@functools.lru_cache(maxsize=4096)
def scale_index(scale: generator.scales.library.Scale, number: int, octave: int = 4) -> int:
"""Exact inverse by sounding pitch; spelling never moves a held note."""
root = scale_pitch(scale, 0, octave).number()
turns, chromatic = divmod(number - root, 12)
if chromatic not in scale.pattern.intervals:
raise ValueError(f'{number} is outside {scale.label()}')
return 7 * turns + scale.pattern.intervals.index(chromatic)
def permitted(choices: tuple[generator.scales.selection.Choice, ...], start: fractions.Fraction, end: fractions.Fraction) -> frozenset[int]:
covered = start
pitches = frozenset(range(12))
+1 -1
View File
@@ -119,7 +119,7 @@ class SoloTests(unittest.TestCase):
self.assertEqual(result.phrases[0].plan.motif.id, result.phrases[1].plan.motif.id)
self.assertFalse(any(d.treatment.startswith('quote') for p in result.phrases for d in p.decisions))
original = result.phrases[0].plan
explicit = dataclasses.replace(original, treatment='exact_quote', target=None)
explicit = dataclasses.replace(original, treatment='exact_quote', target=None, anchor_options=(), gestures=(), passage_id='', passage_start=None, passage_end=None)
first = generator.solo.realization.compose(explicit, source['staves'][0]['measures'], result.choices, tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), result.tempo)
quote = next(d for d in first.decisions if d.treatment.startswith('quote'))
self.assertEqual(len(quote.notes), len(first.plan.motif.notes))
+3 -3
View File
@@ -29,7 +29,7 @@ def choices(root='C', fifths=0, end=8):
def plan(intent='active'):
motif = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4))
passage = generator.solo.types.Passage(fractions.Fraction(), fractions.Fraction(8), intent, fractions.Fraction(1) if intent == 'active' else fractions.Fraction(1, 8) if intent == 'sustained_arrival' else fractions.Fraction(), fractions.Fraction(3, 4) if intent == 'active' else fractions.Fraction(1) if intent == 'sustained_arrival' else fractions.Fraction())
passage = generator.solo.types.ActivitySpan(fractions.Fraction(), fractions.Fraction(8), intent, fractions.Fraction(1) if intent == 'active' else fractions.Fraction(1, 8) if intent == 'sustained_arrival' else fractions.Fraction(), fractions.Fraction(3, 4) if intent == 'active' else fractions.Fraction(1) if intent == 'sustained_arrival' else fractions.Fraction())
return generator.solo.types.PhrasePlan('fixture', passage.start, passage.end, motif, 'statement', 'thematic', None, (59, 75), passage.activity, fractions.Fraction(), (passage,))
def note(start, end, letter='g', alter=0, octave=4, **kwargs):
@@ -76,7 +76,7 @@ class ScoringTests(unittest.TestCase):
class DevelopmentTests(unittest.TestCase):
def test_connected_motion_can_cross_a_bar_and_arrive_on_a_sustain(self):
source = tests.test_solo.song(count=4, chords=('CM7',))
passage = dataclasses.replace(plan(), end=fractions.Fraction(16), treatment='contour_reference', passages=(generator.solo.types.Passage(0, 8, 'active', fractions.Fraction(2), fractions.Fraction(1)), generator.solo.types.Passage(8, 16, 'sustained_arrival', fractions.Fraction(1, 8), fractions.Fraction(1))), target=generator.solo.types.TargetEvent(fractions.Fraction(8), fractions.Fraction(8), frozenset({4}), 'CM7', 'fixture arrival'))
passage = dataclasses.replace(plan(), end=fractions.Fraction(16), treatment='contour_reference', passages=(generator.solo.types.ActivitySpan(0, 8, 'active', fractions.Fraction(2), fractions.Fraction(1)), generator.solo.types.ActivitySpan(8, 16, 'sustained_arrival', fractions.Fraction(1, 8), fractions.Fraction(1))), target=generator.solo.types.TargetEvent(fractions.Fraction(8), fractions.Fraction(8), frozenset({4}), 'CM7', 'fixture arrival'))
phrase = generator.solo.realization.compose(passage, source['staves'][0]['measures'], choices(end=16), tests.solo_fixtures.library(), generator.rhythm.load_library(pathlib.Path('data/rhythms')), fractions.Fraction(120))
notes = generator.solo.composer.phrase_notes(phrase)
self.assertGreaterEqual(sum(n.start < 8 for n in notes), 12)
@@ -148,7 +148,7 @@ class DevelopmentTests(unittest.TestCase):
passage = plan()
left = (note(0, 2, 'c'), note(2, 4, 'e'), note(4, 8, 'g'))
right = (note(0, 1, 'a'), note(1, 4, 'c'), note(4, 8, 'g'))
self.assertNotEqual(generator.solo.realization.state_identity((0, (), left[-1], 0, left), passage), generator.solo.realization.state_identity((0, (), right[-1], 0, right), passage))
self.assertNotEqual(generator.solo.types.ContinuationState(passage.id, left, last=left[-1]), generator.solo.types.ContinuationState(passage.id, right, last=right[-1]))
def test_lookahead_can_choose_a_lower_ranked_statement(self):
first = dataclasses.replace(plan(), end=fractions.Fraction(4), ending='connected')
second = dataclasses.replace(plan(), id='answer', start=fractions.Fraction(4), relationship='answer')
+197
View File
@@ -0,0 +1,197 @@
import dataclasses
import fractions
import unittest
import generator.harmony
import generator.model
import generator.scales.selection
import generator.scales.timeline
import generator.solo.harmonic
import generator.solo.motifs
import generator.solo.scoring
import generator.solo.types
import tests.scale_fixtures
import tests.test_solo_development
F = fractions.Fraction
note = tests.test_solo_development.note
def choices(symbols, duration=4, scale=None):
scale = scale or tests.scale_fixtures.library().scale('major', 6, 'D')
return tuple(generator.scales.selection.Choice(generator.scales.timeline.ChordSpan(F(i * duration), F((i + 1) * duration), i + 1, str(i + 1), F(1), generator.model.key_from_fifths(-1), generator.harmony.parse_symbol(symbol)), scale, 'fixture') for i, symbol in enumerate(symbols))
def anchor(role='terminal', pitch='e', earliest=2, latest=2, duration=2, onset=False):
return generator.solo.types.Anchor('arrival', F(earliest), F(latest), note(0, 1, pitch).pitch, frozenset({4}), (55, 79), F(duration), role, 'C7', onset, F(4) if role == 'terminal' else None)
class HarmonicPassageTests(unittest.TestCase):
def test_same_collection_distinguishes_chord_function(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7', 'FM7', 'BbM7')))
self.assertEqual([s.roles.basic for s in harmony.spans], [frozenset(v) for v in ({7, 10, 2, 5}, {0, 4, 7, 10}, {5, 9, 0, 4}, {10, 2, 5, 9})])
def test_semantic_degrees_keep_alterations_omissions_suspensions_and_slash_bass(self):
for symbol in ('C7b5', 'C9', 'Csus4', 'C7no5', 'C/E', 'C/D'):
chord = generator.harmony.parse_symbol(symbol)
roles = generator.solo.harmonic.roles(chord)
self.assertFalse(roles.basic & roles.specified)
self.assertNotIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C/D')).basic)
self.assertIn(2, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C9')).specified)
self.assertIn(5, generator.solo.harmonic.roles(generator.harmony.parse_symbol('Csus4')).basic)
self.assertNotIn(7, generator.solo.harmonic.roles(generator.harmony.parse_symbol('C7no5')).basic)
def test_ordinary_long_destination_requires_chord_member(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
self.assertTrue(harmony.evaluate((note(0, 2, 'e'),)).valid)
self.assertFalse(harmony.evaluate((note(0, 2, 'f'),)).valid)
self.assertLess(harmony.evaluate((note(0, 1, 'e'),)).exposure, harmony.evaluate((note(0, 1, 'f'),)).exposure)
def test_connected_passing_motion_is_permitted(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
notes = (note(0, F(1, 2), 'g'), note(F(1, 2), 1, 'f'), note(1, 2, 'e'))
self.assertEqual(harmony.connected_motion(notes, 1), 'passing')
self.assertTrue(harmony.evaluate(notes).valid)
self.assertEqual(harmony.evaluate(notes).exposure, 0)
def test_common_tone_hold_needs_no_reattack(self):
harmony = generator.solo.harmonic.Harmony(choices(('Em7b5', 'A7'), scale=tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')))
self.assertTrue(harmony.evaluate((note(2, 6, 'g'),)).valid)
def test_held_tension_detected_without_scale_change(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
notes = (note(2, 6, 'f'),)
self.assertFalse(harmony.evaluate(notes).valid)
self.assertEqual([v['function'] for v in harmony.describe(notes)], ['basic', 'mapped_motion'])
def test_prepared_suspension_requires_actual_resolution(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
treatment = generator.solo.types.HarmonicTreatment('suspension', 'suspension', 65, F(4), F(5), 64, F(5))
notes = (note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'e'))
self.assertTrue(harmony.evaluate(notes, (treatment,), complete=True).valid)
partial = (note(2, F(9, 2), 'f'),)
result = harmony.evaluate(partial, (treatment,), until=F(9, 2))
self.assertTrue(result.valid)
self.assertEqual(result.pending, (treatment,))
self.assertFalse(harmony.evaluate(partial, (treatment,), until=6, complete=True).valid)
self.assertFalse(harmony.evaluate((note(4, F(9, 2), 'f'), note(F(9, 2), 6, 'e')), (treatment,), complete=True).valid)
self.assertFalse(harmony.evaluate((note(2, F(9, 2), 'f'), note(F(9, 2), 6, 'g')), (treatment,), complete=True).valid)
def test_symbol_specified_color_requires_a_plan(self):
harmony = generator.solo.harmonic.Harmony(choices(('C9',)))
notes = (note(0, 2, 'd'),)
color = generator.solo.types.HarmonicTreatment('ninth', 'color', 62, F(0), F(2))
self.assertFalse(harmony.evaluate(notes).valid)
self.assertTrue(harmony.evaluate(notes, (color,)).valid)
ordinary = generator.solo.harmonic.Harmony(choices(('C7',)))
self.assertFalse(ordinary.evaluate(notes, (color,)).valid)
def test_quotation_tension_preserved_and_reported(self):
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
notes = (note(0, 2, 'f', role='quote'),)
self.assertTrue(harmony.evaluate(notes).valid)
report = harmony.describe(notes)
self.assertTrue(report[0]['protected'])
self.assertEqual(report[0]['function'], 'mapped_motion')
def test_partition_invariance_for_roles_duration_activity_and_score(self):
harmony = generator.solo.harmonic.Harmony(choices(('Gm7', 'C7')))
whole = (note(2, 6, 'f'),)
split = (note(2, 3, 'f'), note(3, 4, 'f', continuation=True), note(4, 6, 'f', continuation=True))
self.assertEqual(harmony.evaluate(whole), harmony.evaluate(split))
self.assertEqual(harmony.describe(whole), harmony.describe(split))
plan = tests.test_solo_development.plan()
self.assertEqual(generator.solo.scoring.features(plan, whole, harmony.choices), generator.solo.scoring.features(plan, split, harmony.choices))
def test_intermediate_arrival_does_not_require_closure(self):
notes = (note(0, 1, 'g'), note(1, 2, 'e'), note(2, 3, 'f'), note(3, 4, 'g'))
middle = anchor('intermediate', earliest=1, latest=1, duration=1)
self.assertTrue(generator.solo.harmonic.anchors_valid((middle,), notes, F(4), complete=True)[0])
def test_terminal_touch_then_departure_is_not_closure(self):
terminal = dataclasses.replace(anchor(), duration=F(1, 2), hold_until=None)
notes = (note(2, F(5, 2), 'e'), note(F(5, 2), 4, 'g'))
self.assertFalse(generator.solo.harmonic.anchors_valid((terminal,), notes, F(4), complete=True, closure='closed')[0])
self.assertTrue(generator.solo.harmonic.anchors_valid((anchor(),), (note(2, 4, 'e'),), F(4), complete=True, closure='closed')[0])
def test_existing_hold_can_satisfy_anchor_only_when_allowed(self):
notes = (note(0, 4, 'e'),)
self.assertIsNotNone(generator.solo.harmonic.anchor_note(anchor(), notes))
self.assertIsNone(generator.solo.harmonic.anchor_note(anchor(onset=True), notes))
def test_single_unrelated_note_is_not_perfect_motif_evidence(self):
motif = generator.solo.types.Motif('idea', (note(0, 1, 'c'), note(1, 2, 'd'), note(2, 3, 'g')), F(3), ())
for treatment in ('contour_reference', 'rhythmic_reference', 'opening_reference', 'answer'):
self.assertGreater(generator.solo.scoring.motif_fidelity((note(0, 1, 'e'),), motif, treatment), 0)
def test_scale_index_round_trips_negative_and_octave_crossings(self):
for scale in (tests.scale_fixtures.library().scale('major', 6, 'D'), tests.scale_fixtures.library().scale('harmonic_minor', 1, 'D')):
for index in range(-21, 28):
self.assertEqual(generator.solo.types.scale_index(scale, generator.solo.types.scale_pitch(scale, index).number()), index)
scale = tests.scale_fixtures.library().scale('major', 6, 'D')
self.assertEqual(generator.solo.types.scale_index(scale, 65) - generator.solo.types.scale_index(scale, 64), 1)
class PassageStateTests(unittest.TestCase):
def test_eight_bar_cadence_carries_realized_identity_and_anchor_line(self):
import tests.test_solo
solo = tests.test_solo.compose(tests.test_solo.song(count=8, chords=('Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7')))
self.assertEqual(len(solo.passages), 1)
self.assertEqual((solo.passages[0].start, solo.passages[0].end), (0, 32))
self.assertEqual([(p.plan.start, p.plan.end) for p in solo.phrases], [(0, 16), (16, 32)])
first, second = solo.phrases
self.assertEqual(first.plan.ending, 'connected')
self.assertEqual(first.state.motifs, second.state.motifs[:len(first.state.motifs)])
self.assertEqual(first.anchors, second.anchors)
self.assertTrue(any(a.role == 'intermediate' for a in second.anchors))
self.assertTrue(all(generator.solo.harmonic.anchor_note(a, second.state.notes) for a in second.anchors))
heard = {m.id: m for m in second.state.motifs}
self.assertGreaterEqual(len(heard), 5)
for motif in list(heard.values())[1:]:
self.assertIn(motif.parent, heard)
self.assertTrue(motif.chord_degrees)
self.assertTrue(generator.solo.harmonic.anchors_valid(second.anchors, solo.notes(), solo.end, complete=True, closure='closed')[0])
def test_development_references_realization_instead_of_source_template(self):
plan = tests.test_solo_development.plan()
harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
gesture = generator.solo.types.Gesture('heard', F(0), F(4), 'statement', 'opening_reference')
notes = (note(0, 1, 'e'), note(1, 2, 'f'), note(2, 4, 'g'))
valid, _, realized = generator.solo.motifs.complete(gesture, plan.motif, notes, harmony)
self.assertTrue(valid)
following = dataclasses.replace(gesture, id='answer', start=F(4), end=F(8), relationship='answer')
self.assertEqual(generator.solo.motifs.reference(plan, following, (realized,)), realized)
self.assertEqual(realized.contour(), (1, 2))
self.assertNotEqual(realized.contour(), plan.motif.contour())
def test_concrete_pool_includes_continuation_from_actual_incoming_register(self):
import generator.solo.realization
import tests.solo_fixtures
plan = tests.test_solo_development.plan()
incoming = note(0, 4, 'c', octave=5)
state = generator.solo.types.ContinuationState('passage', (incoming,), last=incoming)
cell = generator.solo.realization.Cell(F(4), F(6), (('moving', ((F(0), F(1, 2)), (F(1, 2), F(1)), (F(1), F(3, 2)), (F(3, 2), F(2)))),))
pool = generator.solo.realization.candidates(cell, plan, choices(('C7', 'C7')), tests.solo_fixtures.library(), state)
self.assertTrue(pool)
self.assertTrue(any(c.notes[0].pitch.number() == incoming.pitch.number() for c in pool))
self.assertTrue(any(c.notes[0].continuation for c in pool))
def test_pending_suspension_guides_next_cells_candidate_pool(self):
import generator.solo.realization
import tests.solo_fixtures
harmony = choices(('Gm7', 'C7'))
held = note(2, F(9, 2), 'f')
contract = generator.solo.types.HarmonicTreatment('pending', 'suspension', 65, F(4), F(9, 2), 64, F(5))
state = generator.solo.types.ContinuationState('passage', (held,), treatments=(contract,), last=held)
cell = generator.solo.realization.Cell(F(9, 2), F(6), (('answer', ((F(0), F(1, 2)), (F(1, 2), F(1)), (F(1), F(3, 2)))),))
pool = generator.solo.realization.candidates(cell, tests.test_solo_development.plan(), harmony, tests.solo_fixtures.library(), state)
self.assertTrue(pool)
self.assertTrue(all(c.notes[0].pitch.number() == 64 for c in pool))
for candidate in pool:
checked = generator.solo.harmonic.Harmony(harmony).evaluate((*state.notes, *candidate.notes), candidate.treatments, until=6, complete=True)
self.assertTrue(checked.valid)
self.assertFalse(checked.pending)
def test_incompatible_anchor_cannot_retime_a_protected_quote(self):
import generator.solo.realization
import tests.test_solo
import tests.solo_fixtures
import tests.rhythm_fixtures
plan = tests.test_solo_development.plan()
demanded = generator.solo.types.Anchor('incompatible', F(1, 4), F(1, 4), plan.motif.notes[1].pitch, frozenset({plan.motif.notes[1].pitch.number() % 12}), plan.register, F(1, 2), 'opening', 'CM7', True)
plan = dataclasses.replace(plan, treatment='exact_quote', anchor_options=((demanded,),))
source = tests.test_solo.song(count=2, chords=('CM7',))
with self.assertRaises(generator.solo.realization.SearchExhausted):
generator.solo.realization.compose(plan, source['staves'][0]['measures'], tests.test_solo_development.choices(), tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), F(120))
def test_phrase_lookahead_does_not_double_count_shared_passage_prefix(self):
import generator.solo.composer
first = dataclasses.replace(tests.test_solo_development.plan(), passage_id='shared', start=F(0), end=F(4))
second = dataclasses.replace(first, id='second', start=F(4), end=F(8))
left = generator.solo.types.Phrase(first, (generator.solo.types.Decision(F(0), F(4), 'fixture', 'invented', (note(0, 4, 'c'),)),), 1.0)
right = generator.solo.types.Phrase(first, (generator.solo.types.Decision(F(0), F(4), 'fixture', 'invented', (note(0, 4, 'g'),)),), 0.5)
def realize(plan, previous):
if plan.id == first.id:
return (left, right)
score = 0.6 if previous.pitch.letter == 'c' else 0.7
continuation = dataclasses.replace(previous, start=F(4), end=F(8))
return tuple([generator.solo.types.Phrase(second, (generator.solo.types.Decision(F(4), F(8), 'fixture', 'answer', (continuation,)),), score)])
chosen = generator.solo.composer.choose_phrases((first, second), realize)
self.assertEqual(chosen[0], left)