Soloing
ci / checks (push) Successful in 1h9m55s

This commit is contained in:
khodowany
2026-10-01 08:35:12 -05:00
parent eae432aa83
commit 75655786e9
16 changed files with 1015 additions and 161 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ 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/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 recurring motifs, harmonic arrivals, and space. 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. 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.
To inspect generated assets without building the full package, run: To inspect generated assets without building the full package, run:
+11 -5
View File
@@ -190,17 +190,23 @@ Scale labels appear above chord symbols at collection changes and system starts.
### Phrase Composition ### Phrase Composition
`generator/solo/` is build-only. `analysis.py` extracts one- and two-bar monophonic motifs from the normalized melody, collapsing ties while retaining repeated attacks, leading rests, source event IDs, and harmonic context. `planning.py` selects at most three motif identities and plans statements, returns, variations, answers, a register peak, and a final callback around two- to four-bar phrase boundaries. Melody breathing points and harmonic destinations influence those boundaries. `types.py` owns exact-time motif, phrase, target, and sounding-pitch models. No annotations are added to source XML. `generator/solo/` is build-only. `analysis.py` extracts one- and two-bar monophonic motifs from the normalized melody, collapsing ties while retaining repeated attacks, leading rests, source event IDs, and harmonic context. `planning.py` selects up to three recurring motif identities and plans statements, returns, variations and answers around two- to four-bar boundaries. Each phrase has active passages, a sustained arrival and optional intentional space, plus entrance, intermediate and arrival register windows. Melody breathing points and harmonic destinations influence phrase boundaries. `types.py` owns the exact rational timing models. No annotations are added to source XML.
`sequences.py` validates the C-major interval paths in `data/sequences/2_4`, `3_4`, and `4_4`, then indexes them by attack count and ranks their range, largest leap, and contour turns. Steps accumulate through octaves, including negative scale indices. Sequence meter describes the source container, not the realized duration. `realization.py` matches those paths to rhythm attacks, not rests or tied continuations. It also supports single targets, explicit repeated notes, holds, and silence. Rhythm snippets retain their meter and exact durations; shorter fitting cells and melody rhythms are not stretched. Catalog entries are ranked by phrase function rather than sampled uniformly or cycled to fill time. `sequences.py` validates all 2-, 3- and 4-note paths in `data/sequences`, then retrieves representatives by displacement, direction, shape and destination. Longer paths chain catalog entries through a shared endpoint and report every source. Steps accumulate through octaves, including negative scale indices. Sequence meter describes the source container, not the realized duration. `realization.py` fits paths to attacks from `data/rhythms`, retaining connected eighth-note patterns and slicing at the actual bar offset without stretching time. It also supports single targets, repeated notes, holds, and silence. Thematic contour and harmonic adaptation continue across cells and collection changes; every held pitch must remain permitted for its entire duration.
Phrase search retains at most 64 distinct endings and considers at most 32 ranked extensions per state. Ordered preferences protect motif identity, register/activity, contour, connection, and playability. Arrival windows require actual defining chord tones, respecting suspensions and explicit alterations. Quotes retain all pitches and attack/release timing, with a whole-fragment octave relocation reported explicitly. Other treatments include harmonic adaptation, changed endings, fragmentation, and rhythmic displacement. A scale change need not end the phrase; held pitches must remain permitted throughout. Missing harmony means silence. Chromatic ornaments, swing, dynamics, and automatic solo chords are not added. `scoring.py` evaluates complete sounding prefixes against the activity planned so far. Attack density and sounding occupancy are separate, and explicit ties count as one attack. Active or sustained passages cannot disappear through a missing register value; intentional space and no-harmony regions have different obligations. A register corridor charges only pitches outside its bounds. Intermediate landmarks are evaluated once their windows close. Duration-normalized, bounded penalties use the curated weights in `policy.py`: activity 4, occupancy 4, motif fidelity 3, landmarks 3, register/contour/connection/repetition 1 each, guitar movement 0.5. Fixed evaluation windows and bounded caches avoid repeating harmonic analysis for every extension. Soft ranking uses floating-point penalties; timing, scale validity, protected quotes, required arrivals and guitar placement retain exact constraints.
Acoustic starts with melody-related material and a softer activity/register preference; electric introduces a contrasting sequence-derived motif and returns to the melody before the vocal pickup. These are compositional preferences, not fixed notes-per-bar quotas. Each phrase reserves release space. Sounding registers start around G3-D5 and B3-G5 respectively; written guitar notation is one octave above sound, exactly once. `placement.py` chooses frets 1-20 with no open strings, retaining tied positions. Movement cost accounts for the time between attacks at the first declared source tempo, or 120 quarter notes/minute when absent. Runtime tempo remains adjustable; this check is not a guarantee of ease at every tempo. Motif fidelity follows the planned contract: `exact_quote` preserves pitches and attack/release timing, permitting only a whole-fragment octave relocation; `rhythmic_reference` preserves timing while changing pitches; `contour_reference` retains direction changes; `opening_reference` protects the beginning; `answer` permits a changed ending; and `invented` develops its own sequence-derived identity. Exact quotation remains explicitly available but is not scheduled automatically. Invented motifs depend on harmony, starting register, shape and destination. Stable musical-content identities ignore file paths and titles; reports record both identity and the policy fingerprint.
Phrase search retains at most 64 states and considers 32 diverse extensions per state. Its identity includes activity, rhythm, landmark and motif progress as well as the placed ending. Up to four completed alternatives are retained, and `composer.py` evaluates the next phrase from those endings before committing. Closed arrivals prefer defining chord tones; open statements may target a compatible fifth or extension, respecting explicit alterations and omissions. A bounded-search failure identifies the passage and limits, not mathematical impossibility.
Acoustic begins with a transformed melody reference; electric begins with a contextual invention. Soft activity goals average 4.5 and 5.5 attacks per four beats respectively, distributed unevenly across movement and arrival. These are not per-bar quotas. Boundaries may connect, sustain or breathe; only the returning vocal pickup has mandatory protection. Sounding registers start around G3–D5 and B3–G5 respectively; written guitar notation is one octave above sound, exactly once. `placement.py` uses frets 1–20 with no open strings and preserves positions through explicit sustain continuations. Movement cost uses the first declared source tempo, or 120 quarter notes/minute. Runtime tempo remains adjustable, so this is not a guarantee of ease at every tempo. Chromatic ornaments, expressive articulation, dynamics and swing playback remain deferred.
`arrangement.py` analyzes each song once and replaces only the two solo-section windows after normal head/accompaniment placement. Electric backs Acoustic Solo; acoustic backs Electric Solo. Neither guitarist accompanies itself. Backing rhythms use the generated solo's foreground and the reserved vocal return, not the absent original melody. Electric may reuse the corresponding placed head grip only when its pitches fit the active solo collection; otherwise ordinary chord-tone placement applies. Existing chord constraints remain unchanged. All non-solo measures and the complete vocal part are regression boundaries. The vocal pickup cutoff is reserved before composing the electric ending. `arrangement.py` analyzes each song once and replaces only the two solo-section windows after normal head/accompaniment placement. Electric backs Acoustic Solo; acoustic backs Electric Solo. Neither guitarist accompanies itself. Backing rhythms use the generated solo's foreground and the reserved vocal return, not the absent original melody. Electric may reuse the corresponding placed head grip only when its pitches fit the active solo collection; otherwise ordinary chord-tone placement applies. Existing chord constraints remain unchanged. All non-solo measures and the complete vocal part are regression boundaries. The vocal pickup cutoff is reserved before composing the electric ending.
`notation.py` converts placed attack spans into ordinary score events, rests, and tied continuations across notation/bar boundaries. Existing engraving, playback compilation, and C++ consumption require no new runtime representation. `report.py` appends phrase/motif/source IDs, transformations, target windows, sounding pitches, and TAB decisions to the build analysis report. A bounded-search failure identifies the passage and search limits; it does not claim mathematical impossibility. Generation still stages and validates the complete output before publication. Listening and playing remain necessary to accept the musical result. `notation.py` converts placed attack spans into ordinary score events, rests, and tied continuations across notation/bar boundaries. Engraving, playback and C++ consumption retain their existing formats. `report.py` appends motif/source IDs, transformation contracts, activity and landmark plans, target windows, sounding pitches, TAB decisions and search diagnostics. Generation still stages and validates the complete output before publication. Listening and playing remain necessary to accept the musical result.
`python -m generator.solo.audition --capture D` captures arrangements, reports, full-solo metrics, source hashes, policy and implementation fingerprints under `build/solo_auditions/D`. The build-only utility refuses to overwrite a snapshot. Capture `baseline`, `A`, `B` and `C` from their corresponding implementation checkpoints before capture D; these labels do not select runtime presets. `--render` creates eight-bar MusicXML and WAV comparisons for each song and guitar using the baseline backing, plus final responsive-backing clips. All clips use existing tone generation, source tempo and a shared gain. `index.html` provides playback and links to reports; `manifest.json` records reproducibility metadata. Metrics cover activity, occupancy, unplanned gaps, connected eighths, quotation and surviving candidate families. They diagnose behavior rather than establish musical quality.
### Engraving Boundary ### Engraving Boundary
+11
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import dataclasses import dataclasses
import fractions import fractions
import hashlib import hashlib
import json
import generator.harmony import generator.harmony
import generator.model import generator.model
@@ -16,6 +17,16 @@ class Analysis:
tempo: fractions.Fraction tempo: fractions.Fraction
melody: tuple[generator.solo.types.Note, ...] melody: tuple[generator.solo.types.Note, ...]
def identity(analysis, measures, part):
anchor = analysis.melody[0].pitch.number() if analysis.melody else 0
melody = [(str(n.start), str(n.end), n.pitch.number() - anchor) for n in analysis.melody]
harmony = []
for measure in measures:
for chord in measure['harmonies']:
pitches = sorted((int(p) - anchor) % 12 for p in generator.harmony.pitch_classes(chord)) if chord['kind'] != 'none' else []
harmony.append((chord['kind'], pitches, chord['onset']))
return hashlib.sha256(json.dumps((part, melody, harmony), sort_keys=True).encode()).hexdigest()
def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]: def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]:
"""Collapse tied tones before extracting monophonic melody fragments.""" """Collapse tied tones before extracting monophonic melody fragments."""
result, pending = [], {} result, pending = [], {}
+3 -1
View File
@@ -84,7 +84,7 @@ def backing(baseline, foreground, part, rhythms, references, choices, initial=No
generator.model.validate_document(document) generator.model.validate_document(document)
return document['staves'][0]['measures'] return document['staves'][0]['measures']
def apply(source, parts, *, rhythms, scales, sequences): def apply(source, parts, *, rhythms, scales, sequences, solos=None):
"""Compose once per song, replacing only already-placed solo section windows.""" """Compose once per song, replacing only already-placed solo section windows."""
analysis = generator.solo.analysis.analyze(source) analysis = generator.solo.analysis.analyze(source)
sections = regions(source) sections = regions(source)
@@ -103,6 +103,8 @@ def apply(source, parts, *, rhythms, scales, sequences):
if sounding: if sounding:
cutoff -= generator.model.fraction(pickup['duration']) - min(sounding) cutoff -= generator.model.fraction(pickup['duration']) - min(sounding)
solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial) solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial)
if solos is not None:
solos.append(solo)
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part) foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
generator.solo.notation.replace_region(parts[part], first, foreground) generator.solo.notation.replace_region(parts[part], first, foreground)
annotate(parts[part], solo, offset) annotate(parts[part], solo, offset)
+210
View File
@@ -0,0 +1,210 @@
"""Build-only snapshots and like-for-like listening comparisons; no engine presets."""
from __future__ import annotations
import argparse
import array
import collections
import copy
import fractions
import hashlib
import html
import importlib
import json
import pathlib
import sys
import time
import wave
import generator.arrange.generate
import generator.model
import generator.musicxml
import generator.musicxml_writer
import generator.parts
import generator.playback
import generator.rhythm
import generator.scales.library
import generator.solo.arrangement
import generator.solo.report
import generator.solo.sequences
STAGES = ('baseline', 'A', 'B', 'C', 'D')
def metrics(solo):
notes = solo.notes()
gaps, cursor, run, runs = [], fractions.Fraction(), 0, []
for note in notes:
if note.start > cursor:
gaps.append((cursor, note.start))
if note.end - note.start == fractions.Fraction(1, 2):
run = run + 1 if note.start == cursor else 1
else:
run = 0
runs.append(run)
cursor = note.end
if cursor < solo.end:
gaps.append((cursor, solo.end))
planned = [(p.start, p.end) for phrase in solo.phrases for p in getattr(phrase.plan, 'passages', ()) if p.intent == 'space']
planned.extend((p.plan.end - p.plan.release, p.plan.end) for p in solo.phrases if p.plan.release)
planned.extend((c.span.start, c.span.end) for c in solo.choices if c.scale is None)
unplanned = []
for start, end in gaps:
fragments = [(start, end)]
for a, b in planned:
fragments = [(x, y) for low, high in fragments for x, y in ((low, min(high, a)), (max(low, b), high)) if y > x]
unplanned.extend(b - a for a, b in fragments)
quoted = sum(n.end - n.start for n in notes if n.role == 'quote')
return {
'phrases': len(solo.phrases), 'attacks': len(notes),
'attacks_per_four_beats': float(4 * len(notes) / solo.end),
'sounding_occupancy': float(sum(n.end - n.start for n in notes) / solo.end),
'longest_unplanned_gap_beats': float(max(unplanned, default=0)),
'longest_connected_eighth_run': max(runs, default=0),
'quotation_coverage': float(quoted / solo.end),
'treatments': dict(collections.Counter(d.treatment for p in solo.phrases for d in p.decisions)),
'search': getattr(solo, 'diagnostics', {}),
}
def fingerprint():
digest = hashlib.sha256()
for path in sorted(pathlib.Path('generator').rglob('*.py')):
digest.update(path.as_posix().encode())
digest.update(path.read_bytes())
return digest.hexdigest()
def capture(root, stage):
destination = root / stage
if destination.exists():
raise ValueError(f'{destination}: snapshot already exists; choose a new output root')
rhythms = generator.rhythm.load_library(pathlib.Path('data/rhythms'))
scales = generator.scales.library.load_library(pathlib.Path('data/scales'))
sequences = generator.solo.sequences.load_library(pathlib.Path('data/sequences'))
result = {'stage': stage, 'implementation': fingerprint(), 'songs': {}}
try:
result['policy'] = importlib.import_module('generator.solo.policy').describe()
except ModuleNotFoundError:
result['policy'] = {'baseline': True}
for path in generator.parts.source_paths(pathlib.Path('data/songs')):
started = time.monotonic()
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
plans = generator.arrange.generate.plan_arrangement(document, rhythms)
parts = {part: generator.arrange.generate._arrange_document(document, part, plans) for part in generator.parts.PART_TYPES}
solos = []
generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos)
song = {'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'parts': parts, 'solos': {}, 'elapsed_seconds': time.monotonic() - started}
regions = generator.solo.arrangement.regions(document)
for solo, index in zip(solos, (3, 4)):
first, count, offset, length = regions[index]
song['solos'][solo.part] = {'first': first, 'count': count, 'offset': str(offset), 'length': str(length), 'tempo': str(solo.tempo), 'metrics': metrics(solo), 'report': generator.solo.report.describe(solo, scales)}
print(path.stem, solo.part, json.dumps(song['solos'][solo.part]['metrics']), flush=True)
result['songs'][path.stem] = song
destination.mkdir(parents=True)
(destination / 'snapshot.json').write_text(json.dumps(result, indent=2) + '\n')
def excerpt(document, first, count):
result = copy.deepcopy(document)
staff = result['staves'][0]
staff['measures'] = staff['measures'][first:first + count]
notes = [n for m in staff['measures'] for v in m['voices'] for n in v['notes']]
identities = {p['id'] for n in notes for p in n['pitches']}
events = {n['id'] for n in notes}
for note in notes:
for pitch in note['pitches']:
for key in ('tie_from', 'tie_to'):
if pitch.get(key) not in identities:
pitch.pop(key, None)
staff['tuplets'] = [t for t in staff.get('tuplets', []) if t['start'] in events and t['end'] in events]
tuplets = {t['id'] for t in staff['tuplets']}
for note in notes:
if 'tuplet_ids' in note:
note['tuplet_ids'] = [i for i in note['tuplet_ids'] if i in tuplets]
offset = sum(generator.model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first])
end = offset + sum(generator.model.fraction(m['duration']) for m in staff['measures'])
for key in ('scale_spans', 'octave_shifts'):
staff[key] = [{**s, 'start': generator.model.rational(max(offset, generator.model.fraction(s['start'])) - offset), 'end': generator.model.rational(min(end, generator.model.fraction(s['end'])) - offset)} for s in staff.get(key, []) if generator.model.fraction(s['start']) < end and generator.model.fraction(s['end']) > offset]
generator.model.validate_document(result)
return result
def mix(documents, tempo, root, cache):
timelines = [generator.playback.compile_timeline(document) for document in documents]
rate = generator.playback.SAMPLE_RATE
factor = fractions.Fraction(60 * rate, tempo)
frames = round(max(generator.model.fraction(t['duration']) for t in timelines) * factor)
output = array.array('f', [0]) * (frames + 2400)
for index, timeline in enumerate(timelines):
gain = 1.0 if index == 0 else 0.55
for event in timeline['events']:
pitch = generator.model.fraction(event['pitch'])
if pitch not in cache:
sample = generator.playback.generate_tone(root, pitch)
with wave.open(str(root / sample['path'])) as audio:
values = array.array('h', audio.readframes(audio.getnframes()))
if sys.byteorder != 'little':
values.byteswap()
cache[pitch] = sample, values
sample, values = cache[pitch]
start = round(generator.model.fraction(event['onset']) * factor)
end = round((generator.model.fraction(event['onset']) + generator.model.fraction(event['duration'])) * factor)
for frame in range(start, min(len(output), end + 2400)):
age = frame - start
position = age if age < sample['loop_end'] else sample['loop_start'] + (age - sample['loop_start']) % (sample['loop_end'] - sample['loop_start'])
release = 1 if frame < end else (1 - (frame - end) / 2399) ** 2
output[frame] += gain * release * values[position] / 32767
return output
def render(root):
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]
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']})
# 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
manifest['shared_gain'] = gain
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)
print(f'Rendered {len(clips)} comparisons under {root}', flush=True)
def write_index(root, snapshots):
labels = {'baseline': 'Original engine', 'A': 'Scoring repair', 'B': 'Activity shapes', 'C': 'Motifs and retrieval', 'D': 'Phrase lookahead'}
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Solo comparisons</title><style>body{font:16px system-ui;max-width:1200px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:1rem;border-bottom:1px solid #ccd1c9}audio{width:280px}small{display:block;margin-top:.5rem}</style><h1>Solo comparisons</h1><p>Eight bars from the same solo entrance at the source tempo. All stages use the original backing and one shared mix gain. D also includes the normal backing that responds to the new solo.</p><p>A: scoring and register. B: activity and breathing. C: transformation contracts, contextual motifs and diverse paths. D: one-phrase lookahead. Expressive playback is deferred.</p><p>Metrics below describe the complete solo, not just the excerpt. These are diagnostics; listening and playing determine musical quality. <a href="manifest.json">Build fingerprints and diagnostics</a>.</p>']
for name, song in snapshots['D']['songs'].items():
for part in song['solos']:
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2><table><tr><th>Stage</th><th>Same backing</th><th>Complete solo</th></tr>')
for stage in STAGES:
prefix = stage + '/' + name + '_' + part
metrics = snapshots[stage]['songs'][name]['solos'][part]['metrics']
stats = f"{metrics['attacks_per_four_beats']:.2f} attacks / four beats<br>{metrics['sounding_occupancy']:.0%} sounding occupancy<br>{metrics['longest_connected_eighth_run']} connected eighths<br>{metrics['longest_unplanned_gap_beats']:g}-beat longest unplanned gap"
body.append(f'<tr><td>{stage}: {labels[stage]}<small><a href="{prefix}.musicxml">Notation</a> · <a href="{prefix}.txt">Phrase report</a></small></td><td><audio controls preload="none" src="{prefix}_fixed.wav"></audio></td><td>{stats}</td></tr>')
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 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('--render', action='store_true')
args = parser.parse_args()
capture(args.output, args.capture) if args.capture else render(args.output)
if __name__ == '__main__':
main()
+64 -19
View File
@@ -10,6 +10,7 @@ import generator.scales.selection
import generator.scales.timeline import generator.scales.timeline
import generator.solo.analysis import generator.solo.analysis
import generator.solo.planning import generator.solo.planning
import generator.solo.policy
import generator.solo.realization import generator.solo.realization
import generator.solo.types import generator.solo.types
@@ -21,27 +22,71 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
if choice.span.chord and choice.scale is None: if choice.span.chord and choice.scale is None:
raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog') raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog')
plans = generator.solo.planning.plans(analysis, measures, choices, end, part, sequences) plans = generator.solo.planning.plans(analysis, measures, choices, end, part, sequences)
# A protected quote may change the local collection, never its individual pitches. retained = tuple((plan.start + n.start, plan.start + n.end, {n.pitch.number() % 12}) for plan in plans if plan.treatment in ('quote', 'exact_quote') for n in plan.motif.notes)
for plan in plans: if retained and not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
if plan.treatment != 'quote' or plan.motif.duration > plan.end - plan.start: choices = generator.scales.selection.select(timeline, region, scales, retained=retained)
continue if not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
retained = tuple((plan.start + n.start, plan.start + n.end, {n.pitch.number() % 12}) for n in plan.motif.notes) raise ValueError(f'{part} solo: protected quotation has no exact compatible scale collection')
if all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained): choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in choices)
continue context = generator.solo.realization.SearchContext()
alternative = generator.scales.selection.select(timeline, region, scales, retained=retained)
if all(pc <= generator.solo.types.permitted(alternative, start, finish) for start, finish, pc in retained) and all(c.scale or c.span.chord is None for c in alternative): def realize(plan, previous):
choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in alternative) return generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context)
phrases, previous = [], None
for plan in plans: phrases = choose_phrases(plans, realize)
phrase = generator.solo.realization.compose(plan, measures, choices, sequences, rhythms, analysis.tempo, previous) context.diagnostics['policy'] = generator.solo.policy.describe()['fingerprint']
phrases.append(phrase) context.diagnostics['musical_identity'] = generator.solo.analysis.identity(analysis, measures, part)
notes = tuple(n for d in phrase.decisions for n in d.notes) result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end, context.diagnostics)
if notes:
previous = notes[-1]
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end)
validate(result) validate(result)
return result return result
def phrase_notes(phrase):
return generator.solo.types.sounding_notes(n for d in phrase.decisions for n in d.notes)
def transition_cost(first, second):
before, after = phrase_notes(first), phrase_notes(second)
if not before or not after:
return 0.0
gap = max(0, after[0].start - before[-1].end - first.plan.release)
jump = abs(after[0].pitch.number() - before[-1].pitch.number())
# The next phrase's local score already includes physical movement. Here we
# evaluate pacing and conversational contrast at the join, once per pair.
before_shape = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(before, before[1:]))
after_shape = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(after, after[1:]))
repeated_answer = second.plan.relationship == 'answer' and before_shape == after_shape
return min(1, float(gap) / 2) + 0.5 * min(1, max(0, jump - 5) / 12) + 0.5 * repeated_answer
def choose_phrases(plans, realize):
"""Commit only after the next phrase has been evaluated from each ending."""
result, previous = [], None
for index, plan in enumerate(plans):
options = realize(plan, previous)
ranked, failure = [], None
for option in options:
notes = phrase_notes(option)
last = notes[-1] if notes else previous
incoming = transition_cost(result[-1], option) if result else 0
if index + 1 == len(plans):
ranked.append((option.score + incoming, option))
continue
try:
following = realize(plans[index + 1], 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)
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:
previous = notes[-1]
return tuple(result)
def validate(solo): def validate(solo):
previous = None previous = None
for note in solo.notes(): for note in solo.notes():
@@ -53,6 +98,6 @@ def validate(solo):
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}') raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
previous = note previous = note
for phrase in solo.phrases: for phrase in solo.phrases:
notes = tuple(n for d in phrase.decisions for n in d.notes) notes = phrase_notes(phrase)
if not generator.solo.realization.target_ok(notes, phrase.plan.target): if not generator.solo.realization.target_ok(notes, phrase.plan.target):
raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized') raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized')
+8 -2
View File
@@ -26,8 +26,14 @@ def place(notes: tuple[generator.solo.types.Note, ...], tempo: fractions.Fractio
following = [] following = []
for position in positions(note.pitch.number()): for position in positions(note.pitch.number()):
current = dataclasses.replace(note, position=position) current = dataclasses.replace(note, position=position)
candidates = [(cost + travel(path[-1] if path else previous, current, tempo), (*path, current)) for cost, path in states] candidates = []
following.append(min(candidates, key=lambda item: (item[0], tuple(n.position for n in item[1])))) for cost, path in states:
prior = path[-1] if path else previous
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start):
continue
candidates.append((cost + (0 if note.continuation else travel(prior, current, tempo)), (*path, current)))
if candidates:
following.append(min(candidates, key=lambda item: (item[0], tuple(n.position for n in item[1]))))
if not following: if not following:
return None return None
states = following states = following
+79 -12
View File
@@ -6,6 +6,7 @@ import generator.harmony
import generator.model import generator.model
import generator.scales.selection import generator.scales.selection
import generator.solo.analysis import generator.solo.analysis
import generator.solo.policy
import generator.solo.sequences import generator.solo.sequences
import generator.solo.types import generator.solo.types
@@ -19,16 +20,44 @@ def target_pitches(chord: dict, final: bool) -> frozenset[int]:
root = generator.scales.selection.root_number(chord) root = generator.scales.selection.root_number(chord)
return frozenset(int(root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items() if d in preferred) or frozenset(generator.harmony.pitch_classes(chord)) return frozenset(int(root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items() if d in preferred) or frozenset(generator.harmony.pitch_classes(chord))
def destination(choices, start, end, final=False, release=fractions.Fraction(1, 2)): def destination(choices, start, end, final=False, release=fractions.Fraction(1, 2), closure='closed'):
last = next((c for c in reversed(choices) if c.span.start < end and c.span.end > start), None) last = next((c for c in reversed(choices) if c.span.start < end and c.span.end > start), None)
if not last or not last.span.chord: if not last or not last.span.chord:
return None return None
latest = end - release - fractions.Fraction(1, 4) latest = end - release - fractions.Fraction(1, 4)
if last.span.start > latest: if last.span.start > latest:
return None return None
return generator.solo.types.TargetEvent(max(start, last.span.start, end - 1 - release), latest, target_pitches(last.span.chord, final), generator.harmony.symbol(last.span.chord), 'phrase resolution' if final else 'defining chord-tone arrival') pitches = target_pitches(last.span.chord, final)
if closure == 'open' and last.scale:
root = generator.scales.selection.root_number(last.span.chord)
degrees = generator.harmony.degrees(last.span.chord)
additions = {int(root + generator.harmony.degree_semitones(d, degrees.get(d, 0))) % 12 for d in (5, 9, 11, 13) if d not in {v['value'] for v in last.span.chord.get('degrees', []) if v['type'] == 'subtract'}}
pitches = frozenset(additions & last.scale.pitches) or pitches
return generator.solo.types.TargetEvent(max(start, last.span.start, end - 2 - release), latest, pitches, generator.harmony.symbol(last.span.chord), 'open continuation' if closure == 'open' else 'phrase resolution' if final else 'defining chord-tone arrival')
def new_motif(sequences: generator.solo.sequences.Library, length: fractions.Fraction) -> generator.solo.types.Motif: def new_motif(sequences: generator.solo.sequences.Library, length: fractions.Fraction, *, choices=(), register=(55, 74), seed='', variant=0) -> generator.solo.types.Motif:
if choices and any(c.scale for c in choices):
scale = next(c.scale for c in choices if c.scale)
arrival = next(c for c in reversed(choices) if c.scale)
count = min(6, max(3, int(length * fractions.Fraction(3, 2))))
shapes = (('rising', 4), ('falling', -4), ('arch', 1), ('valley', -1), ('leap_return', 2), ('neighbor', 0))
shape, displacement = shapes[(int(generator.solo.policy.stable_rank(seed, 'invention')[:8], 16) + variant) % len(shapes)]
paths = sequences.retrieve(count, displacement, (displacement > 0) - (displacement < 0))
candidates = []
target = target_pitches(arrival.span.chord, False)
for path in paths:
for start in range(-14, 15):
pitches = tuple(generator.solo.types.scale_pitch(scale, start + offset) for offset in path.offsets())
if not all(register[0] <= pitch.number() <= register[1] for pitch in pitches):
continue
score = (generator.solo.sequences.family(path.steps) != shape, pitches[-1].number() % 12 not in target, abs(path.offsets()[-1] - displacement), abs(pitches[0].number() - (register[0] + 5)), generator.solo.policy.stable_rank(seed, (variant, path.steps, start)))
candidates.append((score, pitches, path))
if candidates:
_, pitches, path = min(candidates, key=lambda c: c[0])
unit = min(fractions.Fraction(1, 2), length / count)
notes = tuple(generator.solo.types.Note(i * unit, (i + 1) * unit, pitch) for i, pitch in enumerate(pitches))
identifier = generator.solo.policy.stable_rank(seed, (variant, path.steps))[:12]
return generator.solo.types.Motif('invented_' + identifier, notes, length, (), 'sequences ' + ' + '.join(path.sources))
paths = sequences.connecting(3) or sequences.connecting(2) or sequences.connecting(4) paths = sequences.connecting(3) or sequences.connecting(2) or sequences.connecting(4)
if not paths: if not paths:
raise ValueError('solo composition requires a two-, three-, or four-note sequence') raise ValueError('solo composition requires a two-, three-, or four-note sequence')
@@ -49,11 +78,33 @@ def choose_motifs(analysis, sequences, length):
contrast = min(others, key=lambda m: (m.contour() == head.contour(), abs(len(m.notes) - 3), m.duration, m.id)) if others else head contrast = min(others, key=lambda m: (m.contour() == head.contour(), abs(len(m.notes) - 3), m.duration, m.id)) if others else head
return head, contrast, new_motif(sequences, length) return head, contrast, new_motif(sequences, length)
def activity_passages(start, end, release, activity, shape=0):
music_end = end - release
boundaries = sorted({start, music_end} | {start + fractions.Fraction(round(float((end - start) * i / 4) * 2), 2) for i in (1, 2, 3) if start < start + fractions.Fraction(round(float((end - start) * i / 4) * 2), 2) < music_end})
weights = ((1, 1.6, 1.1), (0.8, 1.7, 1.2), (1.5, 1.4, 0.8))[shape % 3]
spans = list(zip(boundaries, boundaries[1:]))
arrival_rate = fractions.Fraction(3, 8)
desired = max(0, activity * (end - start) - arrival_rate * (spans[-1][1] - spans[-1][0]))
denominator = sum(float(b - a) * weights[min(i, 2)] for i, (a, b) in enumerate(spans[:-1]))
result = []
for i, (a, b) in enumerate(spans):
arrival = i == len(spans) - 1
rate = arrival_rate if arrival else fractions.Fraction(str(min(2, float(desired) * weights[min(i, 2)] / max(denominator, 0.001)))).limit_denominator(1000)
occupancy = generator.solo.policy.DEFAULT.arrival_occupancy if arrival else generator.solo.policy.DEFAULT.active_occupancy
result.append(generator.solo.types.Passage(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()))
return tuple(result)
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, ...]: 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()] boundaries = [fractions.Fraction()]
for measure in measures: for measure in measures:
boundaries.append(min(end, boundaries[-1] + generator.model.fraction(measure['duration']))) boundaries.append(min(end, boundaries[-1] + generator.model.fraction(measure['duration'])))
head, contrast, invented = choose_motifs(analysis, sequences, boundaries[1]) 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 result, index = [], 0
while index < len(measures) and boundaries[index] < end: while index < len(measures) and boundaries[index] < end:
start = boundaries[index] start = boundaries[index]
@@ -74,17 +125,33 @@ def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choi
finish = boundaries[stop] finish = boundaries[stop]
final = finish == end final = finish == end
phase = (start + finish) / (2 * end) phase = (start + finish) / (2 * end)
motif = head if part == 'acoustic' or final else invented phrase_seed = generator.solo.policy.stable_rank(seed, (str(start), str(finish)))
if len(result) > 1 and not final and len(result) % 3 == 2: choice = int(phrase_seed[:8], 16)
motif = contrast if part == 'acoustic' else invented relationship = 'statement' if not result else 'repeat' if len(result) == 1 else 'answer' if len(result) % 2 else 'variation'
relationship = 'statement' if not result else 'callback' if final else 'repeat' if len(result) == 1 else 'answer' if len(result) % 2 else 'variation' if part == 'acoustic':
treatment = 'quote' if (part == 'acoustic' and not result) or (part == 'electric' and final and motif.origin == 'melody') else 'thematic' 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]
low, high = (55, 74) if part == 'acoustic' else (59, 79) low, high = (55, 74) if part == 'acoustic' else (59, 79)
# Reserve the upper register for a prepared peak, not every active passage. # Reserve the upper register for a prepared peak, not every active passage.
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) 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(3, 4) if part == 'acoustic' else fractions.Fraction(1) activity = fractions.Fraction(str(generator.solo.policy.DEFAULT.acoustic_activity if part == 'acoustic' else generator.solo.policy.DEFAULT.electric_activity))
if final: ending = ('connected', 'sustain', 'breath')[choice % 3]
activity *= fractions.Fraction(2, 3) release = fractions.Fraction(1, 2) if choice % 2 else fractions.Fraction(1)
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', start, finish, motif, relationship, treatment, destination(choices, start, finish, final), register, activity)) 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))
index = stop index = stop
return tuple(result) return tuple(result)
+28
View File
@@ -0,0 +1,28 @@
"""One curated, deterministic composition policy; all times are quarter-note beats."""
from __future__ import annotations
import dataclasses
import hashlib
import json
@dataclasses.dataclass(frozen=True)
class Policy:
acoustic_activity: float = 1.125
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))
beam_width: int = 64
extensions: int = 32
retrieval: int = 32
alternatives: int = 4
DEFAULT = Policy()
def describe():
values = dataclasses.asdict(DEFAULT)
identity = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()
return {'fingerprint': identity, 'parameters': values}
def stable_rank(seed, value):
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
+203 -118
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import dataclasses import dataclasses
import fractions import fractions
import functools import functools
import heapq import collections
import generator.fretboard import generator.fretboard
import generator.model import generator.model
@@ -11,10 +11,13 @@ import generator.rhythm
import generator.scales.selection import generator.scales.selection
import generator.solo.placement import generator.solo.placement
import generator.solo.planning import generator.solo.planning
import generator.solo.scoring
import generator.solo.sequences
import generator.solo.policy
import generator.solo.types import generator.solo.types
BEAM_WIDTH = 64 BEAM_WIDTH = generator.solo.policy.DEFAULT.beam_width
CELL_LIMIT = 32 CELL_LIMIT = generator.solo.policy.DEFAULT.extensions
class SearchExhausted(ValueError): class SearchExhausted(ValueError):
pass pass
@@ -32,8 +35,9 @@ class Candidate:
source: str source: str
treatment: str treatment: str
rank: tuple rank: tuple
fidelity: float = 0
def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fractions.Fraction, activity: fractions.Fraction): def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fractions.Fraction, activity: fractions.Fraction, offset=fractions.Fraction()):
result = {} result = {}
for snippet in library.for_meter(meter): for snippet in library.for_meter(meter):
for index, bar in enumerate(snippet.bars): for index, bar in enumerate(snippet.bars):
@@ -44,54 +48,52 @@ def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fract
if event.continuation: if event.continuation:
if spans and spans[-1][1] == event.onset: if spans and spans[-1][1] == event.onset:
spans[-1] = (spans[-1][0], event.onset + event.duration) spans[-1] = (spans[-1][0], event.onset + event.duration)
else:
# A continuation without its attack is not an independent cell.
spans = []
break
else: else:
spans.append((event.onset, event.onset + event.duration)) spans.append((event.onset, event.onset + event.duration))
pattern = tuple(spans) # Slice exact authored rhythm; later attacks outside the window do not
if all(end <= length for _, end in pattern) and len(pattern) <= 4: # disqualify a useful prefix. A tied continuation never becomes an attack.
result.setdefault(pattern, f'rhythm {snippet.id}:{index + 1}') pattern = tuple((start - offset, min(end - offset, length)) for start, end in spans if offset <= start < offset + length)
ranked = sorted(result.items(), key=lambda item: (abs(len(item[0]) - length * activity), -sum(end - start for start, end in item[0]), item[1])) result.setdefault(pattern, f'rhythm {snippet.id}:{index + 1}@{offset}+{length}')
return tuple((identifier, pattern) for pattern, identifier in ranked[:8])
def rank(item):
pattern, identifier = item
return abs(len(pattern) - length * activity), abs(sum(end - start for start, end in pattern) - length * fractions.Fraction(3, 4)), identifier
def family(item):
pattern = item[0]
return len(pattern), bool(pattern and pattern[0][0]), any(a.denominator > 1 for a, _ in pattern)
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):
result, start, index = [], fractions.Fraction(), 0 result, boundaries = [], [fractions.Fraction()]
boundaries = [fractions.Fraction()]
for measure in measures: for measure in measures:
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration'])) boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
start = plan.start start, music_end = plan.start, plan.end - plan.release
music_end = plan.end - plan.release protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
arrival_fits = not plan.target or plan.motif.duration < music_end - start or any(plan.target.earliest <= start + n.start <= plan.target.latest for n in plan.motif.notes) if protected:
if plan.motif.duration <= music_end - start and arrival_fits:
motif = plan.motif motif = plan.motif
if not motif.notes or motif.duration > music_end - start:
raise SearchExhausted(f'{plan.id}: protected motif rhythm does not fit the phrase')
spans = tuple((n.start, n.end) for n in motif.notes) spans = tuple((n.start, n.end) for n in motif.notes)
patterns = [(motif.id, spans)] result.append(Cell(start, start + motif.duration, ((motif.id, spans),), motif))
if plan.relationship == 'variation' and spans[-1][1] + fractions.Fraction(1, 2) <= motif.duration:
patterns.insert(0, (motif.id + ':displaced', tuple((a + fractions.Fraction(1, 2), b + fractions.Fraction(1, 2)) for a, b in spans)))
result.append(Cell(start, start + motif.duration, tuple(patterns), motif))
start += motif.duration start += motif.duration
elif plan.motif.notes and plan.motif.duration > music_end - start:
# Fragmentation is an explicit transformation, never a truncated quotation.
available = (plan.target.earliest if plan.target else music_end) - start
notes = tuple(n for n in plan.motif.notes if n.end <= available)
if notes:
motif = dataclasses.replace(plan.motif, notes=notes, duration=notes[-1].end)
result.append(Cell(start, start + motif.duration, ((motif.id + ':fragment', tuple((n.start, n.end) for n in notes)),), motif))
start += motif.duration
while start < music_end: while start < music_end:
index = next(i for i in range(len(measures)) if boundaries[i] <= start < boundaries[i + 1]) index = next(i for i in range(len(measures)) if boundaries[i] <= start < boundaries[i + 1])
end = min(music_end, boundaries[index + 1]) passage = next((p for p in plan.passages if p.start <= start < p.end), None)
patterns = rhythm_patterns(rhythms, measures[index]['time_signature'], end - start, plan.activity) end = min(music_end, boundaries[index + 1], passage.end if passage else music_end)
# These primitives are deliberate alternatives, independent of catalog counts. 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', ())) patterns += (('hold', ((fractions.Fraction(), end - start),)), ('space', ()))
if plan.target and start <= plan.target.latest < end: if plan.target and start <= plan.target.latest and plan.target.earliest < end:
onset = max(start, plan.target.earliest) - start onset = max(start, plan.target.earliest) - start
patterns = (('arrival_and_release', ((onset, min(end - start, onset + 1)),)), *patterns) patterns = (('arrival_and_release', ((onset, end - start),)), *patterns)
result.append(Cell(start, end, patterns)) motif = plan.motif if not result else None
result.append(Cell(start, end, patterns, motif))
start = end start = end
result.append(Cell(music_end, plan.end, (('planned_release', ()),))) if plan.release:
result.append(Cell(music_end, plan.end, (('planned_release', ()),)))
return tuple(result) return tuple(result)
@functools.lru_cache(maxsize=2048) @functools.lru_cache(maxsize=2048)
@@ -110,28 +112,25 @@ 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) 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): def thematic_paths(cell, spans, choices, plan):
states = [(0, ())] states = [(0.0, ())]
for index, (start, end) in enumerate(spans): for index, (start, end) in enumerate(spans):
following = [] following = []
for cost, path in states: available = options(choices, start, end, plan.register)
available = options(choices, start, end, plan.register) for _, path in states:
changing_ending = plan.relationship == 'answer' and index == len(spans) - 1
if changing_ending:
chord = next((c.span.chord for c in choices if c.span.start <= start < c.span.end), None)
if chord:
available = tuple(p for p in available if p.number() % 12 in generator.solo.planning.target_pitches(chord, False))
for pitch in available: for pitch in available:
reference = cell.motif.notes[index] reference = cell.motif.notes[min(len(cell.motif.notes) - 1, index * len(cell.motif.notes) // max(1, len(spans)))]
expected = cell.motif.contour()[index - 1] if index else 0
motion = pitch.number() - path[-1].pitch.number() if path else 0
# Repeated notes are identity, not missing sequence transitions.
mismatch = int(bool(index) and ((expected == 0) != (motion == 0) or expected * motion < 0))
note = generator.solo.types.Note(start, end, pitch, reference.sources, 'motif') note = generator.solo.types.Note(start, end, pitch, reference.sources, 'motif')
following.append((cost + (0 if changing_ending else 8 * mismatch + abs(motion - expected)), (*path, note))) notes = (*path, note)
states = sorted(following, key=lambda item: (item[0], tuple(n.pitch for n in item[1])))[:BEAM_WIDTH] 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: if not states:
break break
return states return [(generator.solo.scoring.motif_fidelity(notes, cell.motif, plan.treatment), notes) for _, notes in states]
def adapt_path(pitches, spans, choices, register): def adapt_path(pitches, spans, choices, register):
"""Connect actual pitches across collections without carrying a stale degree index.""" """Connect actual pitches across collections without carrying a stale degree index."""
@@ -152,102 +151,188 @@ def sequence_paths(spans, choices, plan, sequences):
first_scale = next((c.scale for c in choices if c.span.start <= spans[0][0] < c.span.end), None) 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: if first_scale is None:
return [] return []
paths = []
allowed = tuple(generator.solo.types.permitted(choices, start, end) for start, end in spans) allowed = tuple(generator.solo.types.permitted(choices, start, end) for start, end in spans)
changing = any(classes != allowed[0] for classes in allowed) if not all(allowed):
sequence_options = sequences.connecting(len(spans))[:16] return []
offsets = [(s.offsets(), 'sequence ' + s.id) for s in sequence_options] available = options(choices, *spans[0], plan.register)
# Single notes and explicit repetitions do not pretend to be catalog paths. if len(spans) == 1:
offsets.append(((0,) * len(spans), 'single' if len(spans) == 1 else 'repeated_note')) return [((generator.solo.types.Note(*spans[0], pitch, role='path'),), 'single') for pitch in available]
for steps, identifier in offsets: landmark = next((p for p in plan.landmarks if p.latest >= spans[0][0]), None)
for target_index in range(-14, 21): center = (landmark.low + landmark.high) // 2 if landmark else sum(plan.register) // 2
start_index = target_index - steps[-1] direction = landmark.direction if landmark else 0
pitches = tuple(generator.solo.types.scale_pitch(first_scale, start_index + step) for step in steps) 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): if not all(max(41, plan.register[0] - 7) <= p.number() <= min(84, plan.register[1] + 5) for p in pitches):
continue continue
if all(pitch.number() % 12 in classes for pitch, classes in zip(pitches, allowed)): if all(p.number() % 12 in classes for p, classes in zip(pitches, allowed)):
notes = tuple(generator.solo.types.Note(start, end, pitch, role='path') for (start, end), pitch in zip(spans, pitches)) realized = [tuple(generator.solo.types.Note(start, end, pitch, role='path') for (start, end), pitch in zip(spans, pitches))]
paths.append((notes, identifier)) elif any(classes != allowed[0] for classes in allowed):
elif changing and not paths and steps == offsets[0][0]: realized = adapt_path(pitches, spans, choices, plan.register)
paths.extend((notes, 'harmonic_adaptation of ' + identifier) for notes in 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 return paths
def candidates(cell, plan, choices, sequences): def candidates(cell, plan, choices, sequences):
result = [] result = []
target = plan.target if plan.target and cell.start <= plan.target.latest < cell.end else None target = plan.target if plan.target and cell.start <= plan.target.latest < cell.end else None
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: for identifier, pattern in cell.patterns:
spans = tuple((cell.start + start, cell.start + end) for start, end in pattern) 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)) 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 local_cell = cell
if len(active) != len(spans): if len(active) != len(spans):
if cell.motif and plan.treatment in ('quote', 'exact_quote'):
continue
spans = tuple(spans[i] for i in active) spans = tuple(spans[i] for i in active)
identifier += ':harmonic_fragment' identifier += ':harmonic_fragment'
if cell.motif: if cell.motif:
local_cell = dataclasses.replace(cell, motif=dataclasses.replace(cell.motif, notes=tuple(cell.motif.notes[i] for i in active))) 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 = [] paths = []
if local_cell.motif and spans: if local_cell.motif and spans:
if plan.treatment == 'quote' and ':fragment' not in identifier and ':harmonic_fragment' not in identifier and ':displaced' not in identifier: 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): 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)) 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): if valid_notes(notes, choices):
paths.append((notes, f'quote octave {octaves:+}', 0)) paths.append((notes, f'quote octave {octaves:+}', 0))
for mismatch, notes in thematic_paths(local_cell, spans, choices, plan): if plan.treatment not in ('quote', 'exact_quote'):
treatment = 'fragment' if 'fragment' in identifier else 'rhythmic_displacement' if ':displaced' in identifier else 'changed_ending' if plan.relationship == 'answer' else 'harmonic_adaptation' for mismatch, notes in thematic_paths(local_cell, spans, choices, plan):
paths.append((notes, treatment, 1 + mismatch)) treatment = 'fragment' if 'fragment' in identifier else plan.treatment
paths.append((notes, treatment, mismatch))
else: else:
paths = [(notes, name, 0) for notes, name in sequence_paths(spans, choices, plan, sequences)] paths = [(notes, name, 0) for notes, name in sequence_paths(spans, choices, plan, sequences)]
for notes, treatment, identity in paths: for notes, treatment, identity in paths:
if not target_ok(notes, target): 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)
continue features = evaluation.features(notes)
notes = tuple(dataclasses.replace(n, role='target' if target and target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches else n.role) for n in notes) fidelity = identity if cell.motif else 0
register = 16 * sum(max(0, plan.register[0] - n.pitch.number(), n.pitch.number() - plan.register[1]) for n in notes) features['fidelity'] = fidelity
if notes: rank = (generator.solo.scoring.value(features),)
register += abs(max(n.pitch.number() for n in notes) - sum(plan.register) // 2) result.append(Candidate(notes, identifier, treatment, rank, fidelity))
density = abs(len(notes) - (cell.end - cell.start) * plan.activity)
contour = sum(max(0, abs(b.pitch.number() - a.pitch.number()) - 5) for a, b in zip(notes, notes[1:]))
if not cell.motif and len({n.pitch.number() for n in notes}) == 1:
contour += max(0, len(notes) - 2)
rank = (identity, register, density if not cell.motif else 0, contour)
result.append(Candidate(notes, identifier, treatment, rank))
# A no-harmony passage is intentional silence, not a failed pitch search. # A no-harmony passage is intentional silence, not a failed pitch search.
if not any(c.span.chord and c.span.start < cell.end and c.span.end > cell.start for c in choices): 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, 0, 0, 0))] 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))) return sorted(result, key=lambda c: (c.rank, c.source, c.treatment, tuple(n.pitch for n in c.notes)))
def compose(plan, measures, choices, sequences, rhythms, tempo, previous=None): @dataclasses.dataclass
states = [((0, 0, 0, 0, 0, 0, 0), (), previous)] class SearchContext:
for cell in cells(plan, measures, rhythms): pools: dict = dataclasses.field(default_factory=dict)
pool = candidates(cell, plan, choices, sequences) 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)
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
context = context if context is not None else SearchContext()
key = (plan, previous)
if key in context.results:
context.diagnostics['cache_hits'] += 1
return context.results[key]
context.diagnostics['searches'] += 1
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 = [], {} following, extensions = [], {}
for cost, decisions, last in states: for _, decisions, last, travel, sounded in states:
target_met = target_ok(sounded, plan.target)
def extension_rank(candidate): def extension_rank(candidate):
jump = abs(candidate.notes[0].pitch.number() - last.pitch.number()) if candidate.notes and last else 0 jump = abs(candidate.notes[0].pitch.number() - last.pitch.number()) / 12 if candidate.notes and last else 0
return candidate.rank, jump, candidate.source, candidate.treatment, tuple(n.pitch for n in candidate.notes) return candidate.rank[0] + min(1, jump), candidate.source, candidate.treatment, tuple(n.pitch for n in candidate.notes)
key = last.pitch.number() if last else None extension_key = (last.pitch if last else None, target_met)
if key not in extensions: if extension_key not in extensions:
extensions[key] = heapq.nsmallest(CELL_LIMIT, pool, key=extension_rank) 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)]
for candidate in extensions[key]: 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)
placed = generator.solo.placement.place(candidate.notes, tempo, last) for candidate in extensions[extension_key]:
if placed is None: variants = [candidate.notes]
continue passage = next((p for p in plan.passages if p.start <= cell.start < p.end), None)
comfort, notes = placed 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':
jump = abs(notes[0].pitch.number() - last.pitch.number()) if notes and last else 0 variants.append((dataclasses.replace(candidate.notes[0], continuation=True, role='sustain'),))
monotony = int(bool(notes and decisions and decisions[-1].notes) and tuple(n.pitch for n in notes) == tuple(n.pitch for n in decisions[-1].notes) and cell.motif is None) for variant in variants:
local = (*candidate.rank, jump, comfort, monotony) context.diagnostics['evaluated_extensions'] += 1
total = tuple(a + b for a, b in zip(cost, local)) placed = generator.solo.placement.place(variant, tempo, last)
decision = generator.solo.types.Decision(cell.start, cell.end, candidate.source, candidate.treatment, notes) if placed is None:
following.append((total, (*decisions, decision), notes[-1] if notes else last)) 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))
if not following: 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; no feasible phrase extension among {BEAM_WIDTH} states / {CELL_LIMIT} extensions (not proof that the passage is unplayable)')
following.sort(key=lambda item: (item[0], tuple((d.source, d.treatment, tuple((n.pitch, n.position) for n in d.notes)) for d in item[1])))
# Retain distinct endings for lookahead instead of 64 copies of one path. def rank(state):
return state[0], tuple((n.pitch, n.position, n.start, n.end) for n in state[4])
unique = {} unique = {}
for item in following: for state in sorted(following, key=rank):
last = item[2] unique.setdefault(state_identity(state, plan), state)
key = (last.pitch, last.position, last.end) if last else None 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))
unique.setdefault(key, item) states.sort(key=rank)
states = list(unique.values())[:BEAM_WIDTH] context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
_, decisions, _ = states[0] 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)
return generator.solo.types.Phrase(plan, decisions) for state in selected:
family = note_family(state[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)
context.results[key] = result
return result
def compose(plan, measures, choices, sequences, rhythms, tempo, previous=None):
return alternatives(plan, measures, choices, sequences, rhythms, tempo, previous)[0]
+8
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import json
import generator.harmony import generator.harmony
import generator.model import generator.model
@@ -13,9 +14,16 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo') region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
positioned = tuple(dataclasses.replace(c, span=dataclasses.replace(c.span, start=c.span.start + time_offset, end=c.span.end + time_offset, measure=c.span.measure + measure_offset)) for c in solo.choices) positioned = tuple(dataclasses.replace(c, span=dataclasses.replace(c.span, start=c.span.start + time_offset, end=c.span.end + time_offset, measure=c.span.measure + measure_offset)) for c in solo.choices)
lines = [generator.scales.report.passage(solo.part, region, positioned, scales), f'Phrase composition: {solo.part}', f'Placement reference tempo: {solo.tempo} quarter notes/minute. Runtime tempo changes are not a comfort guarantee.', 'Times below are quarter-note offsets from the start of this solo; pitches are sounding.'] 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))
for phrase in solo.phrases: for phrase in solo.phrases:
plan = phrase.plan 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.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}')
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: if plan.target:
lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}') lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}')
for decision in phrase.decisions: for decision in phrase.decisions:
+102
View File
@@ -0,0 +1,102 @@
"""Duration-normalized musical evaluation, independent of search/notation chunks."""
from __future__ import annotations
import fractions
import generator.solo.policy
import generator.solo.types
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 ())
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)
def overlap(start, end, intervals):
return sum((max(0, min(end, b) - max(start, a)) for a, b in intervals), fractions.Fraction())
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'):
return 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
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):
reference = expected[min(len(expected) - 1, index * len(expected) // max(1, len(actual)))]
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)
class Evaluation:
"""Prepare fixed harmonic windows once; only soft penalties use float time."""
def __init__(self, plan, choices, until=None):
self.plan = plan
self.until = plan.end if until is None else until
self.duration = max(float(self.until - plan.start), 0.001)
self.windows = []
self.required = []
for passage in passages(plan):
end = min(passage.end, self.until)
if end <= passage.start:
continue
active = intervals(choices, passage.start, end)
length = float(sum((b - a for a, b in active), fractions.Fraction()))
if length:
self.windows.append((float(passage.start), float(end), tuple((float(a), float(b)) for a, b in active), length, float(passage.activity) * length, float(passage.occupancy), 0.5 if self.until < passage.end else 0))
if passage.end <= self.until:
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference') and passage.end <= plan.start + plan.motif.duration
authored_silence = protected and not any(overlap(plan.start + n.start, plan.start + n.end, active) for n in plan.motif.notes)
self.required.append((passage, active, authored_silence))
self.landmarks = tuple((float(p.earliest), float(p.latest), p.low, p.high, p.direction) for p in plan.landmarks if p.latest <= self.until)
def feasible(self, notes):
# Hard silence/space requirements retain exact rational boundaries.
for passage, active, authored_silence in self.required:
if passage.intent == 'space':
if any(n.start < passage.end and n.end > passage.start for n in notes):
return False
elif active and not authored_silence and not any(n.start < b and n.end > a for n in notes for a, b in active):
return False
return True
def features(self, notes, *, previous=None, movement=0):
notes = generator.solo.types.sounding_notes(notes)
sounded = tuple((float(n.start), float(n.end), n.pitch.number()) for n in notes)
activity, occupancy = 0.0, 0.0
for start, end, active, length, expected, occupied_goal, tolerance in self.windows:
attacks = sum(start <= a < end and any(x <= a < y for x, y in active) for a, _, _ in sounded)
occupied = sum(max(0, min(b, y) - max(a, x)) for a, b, _ in sounded for x, y in active)
deviation = max(0, abs(attacks - expected) - tolerance)
activity += length * min(1, deviation / max(1, expected))
occupancy += length * abs(occupied / length - occupied_goal)
start, end = float(self.plan.start), float(self.until)
durations = [max(0, min(b, end) - max(a, start)) for a, b, _ in sounded]
register = sum(length * min(1, corridor(pitch, *self.plan.register) / 12) for (_, _, pitch), length in zip(sounded, durations)) / max(sum(durations), 0.001)
motions = [b[2] - a[2] for a, b in zip(sounded, sounded[1:])]
contour = sum(min(1, max(0, abs(m) - 5) / 12) for m in motions) / max(1, len(motions))
repetition = sum(a == b == 0 for a, b in zip(motions, motions[1:])) / max(1, len(motions) - 1)
landmarks = []
for earliest, latest, low, high, direction in self.landmarks:
prior = next((n for n in reversed(sounded) if n[0] < earliest), None)
landmarks.append(min((0.75 * min(1, corridor(n[2], low, high) / 12) + 0.25 * bool(prior and direction and (n[2] - prior[2]) * direction <= 0) for n in sounded if earliest <= n[0] <= latest), default=1))
motif_notes = tuple(n for n in notes if n.role in ('motif', 'quote'))
connection = min(1, abs(sounded[0][2] - previous.pitch.number()) / 12) if sounded and previous else 0
return {'activity': activity / self.duration, 'occupancy': occupancy / self.duration, 'register': register, 'contour': contour, 'repetition': repetition, 'landmarks': sum(landmarks) / max(1, len(landmarks)), 'connection': connection, 'movement': min(1, movement / max(1, len(notes)) / 2000), 'fidelity': motif_fidelity(motif_notes, self.plan.motif, self.plan.treatment) if motif_notes else 0}
def features(plan, notes, choices, *, until=None, previous=None, movement=0):
return Evaluation(plan, choices, until).features(notes, previous=previous, movement=movement)
def value(features):
return sum(weight * features.get(name, 0) for name, weight in generator.solo.policy.DEFAULT.weights)
def feasible(plan, notes, choices, until):
return Evaluation(plan, choices, until).feasible(notes)
+75
View File
@@ -4,6 +4,8 @@ import dataclasses
import pathlib import pathlib
import types import types
import generator.solo.policy
import generator.model import generator.model
import generator.musicxml import generator.musicxml
@@ -20,9 +22,46 @@ class Sequence:
offsets = self.offsets() offsets = self.offsets()
return max(map(abs, self.steps)), max(offsets) - min(offsets), sum(a * b < 0 for a, b in zip(self.steps, self.steps[1:])), self.id return max(map(abs, self.steps)), max(offsets) - min(offsets), sum(a * b < 0 for a, b in zip(self.steps, self.steps[1:])), self.id
@dataclasses.dataclass(frozen=True)
class Path:
steps: tuple[int, ...]
sources: tuple[str, ...]
def offsets(self):
result = [0]
for step in self.steps:
result.append(result[-1] + step)
return tuple(result)
def family(steps):
if not steps or all(s == 0 for s in steps):
return 'repeated'
turns = sum(a * b < 0 for a, b in zip(steps, steps[1:]))
if not turns:
return 'rising' if sum(steps) > 0 else 'falling'
if max(map(abs, steps)) >= 4:
return 'leap_return'
if sum(steps) == 0 and max(map(abs, steps)) == 1:
return 'neighbor'
return ('arch' if steps[0] > 0 else 'valley') if turns == 1 else 'wave'
def diverse(items, rank, category, limit):
"""Round-robin eligible musical families before a destructive cutoff."""
groups = {}
for item in sorted(items, key=rank):
groups.setdefault(category(item), []).append(item)
result, index = [], 0
while len(result) < limit:
layer = [values[index] for values in groups.values() if len(values) > index]
if not layer:
break
result.extend(sorted(layer, key=rank)[:limit - len(result)])
index += 1
return tuple(result)
@dataclasses.dataclass(frozen=True, init=False) @dataclasses.dataclass(frozen=True, init=False)
class Library: class Library:
paths: types.MappingProxyType paths: types.MappingProxyType
chains: dict
def __init__(self, sequences: tuple[Sequence, ...]): def __init__(self, sequences: tuple[Sequence, ...]):
paths, identities = {}, set() paths, identities = {}, set()
for sequence in sequences: for sequence in sequences:
@@ -31,8 +70,44 @@ class Library:
identities.add(sequence.id) identities.add(sequence.id)
paths.setdefault(len(sequence.steps) + 1, []).append(sequence) paths.setdefault(len(sequence.steps) + 1, []).append(sequence)
object.__setattr__(self, 'paths', types.MappingProxyType({count: tuple(sorted(items, key=lambda s: s.rank())) for count, items in paths.items()})) object.__setattr__(self, 'paths', types.MappingProxyType({count: tuple(sorted(items, key=lambda s: s.rank())) for count, items in paths.items()}))
object.__setattr__(self, 'chains', {})
def connecting(self, count: int, displacement: int | None = None) -> tuple[Sequence, ...]: def connecting(self, count: int, displacement: int | None = None) -> tuple[Sequence, ...]:
return tuple(s for s in self.paths.get(count, ()) if displacement is None or s.offsets()[-1] == displacement) return tuple(s for s in self.paths.get(count, ()) if displacement is None or s.offsets()[-1] == displacement)
def chained(self, count):
return self.retrieve(count)
def retrieve(self, count, displacement=0, direction=0):
key = (count, displacement, direction)
if key in self.chains:
return self.chains[key]
if not 1 <= count <= 16:
return ()
states, remaining = (Path((), ()),), count - 1
while remaining:
size = min(3, remaining)
options = self.connecting(size + 1)
if not options:
size = next((n for n in range(size - 1, 0, -1) if self.connecting(n + 1)), 0)
if not size:
return ()
options = self.connecting(size + 1)
desired = displacement * (count - 1 - remaining + size) / max(1, count - 1)
def rank(path):
offsets = path.offsets()
motion = offsets[-1]
return abs(motion - desired) + (2 if direction and motion * direction < 0 else 0), max(offsets) - min(offsets), max(map(abs, path.steps), default=0), path.sources
# Retrieve from the entire authored vocabulary, conditioned on each prefix.
following = []
for prior in states:
if count > 4:
local = self.retrieve(size + 1, round(desired - prior.offsets()[-1]), direction)
paths = (Path(prior.steps + s.steps, prior.sources + s.sources) for s in local)
else:
paths = (Path(prior.steps + s.steps, prior.sources + (s.id,)) for s in options)
following.extend(diverse(paths, rank, lambda p: family(p.steps), generator.solo.policy.DEFAULT.retrieval))
states = diverse(following, rank, lambda p: (family(p.steps), (p.offsets()[-1] > 0) - (p.offsets()[-1] < 0)), generator.solo.policy.DEFAULT.retrieval)
remaining -= size
self.chains[key] = states
return states
def read(path: pathlib.Path, identifier: str, count: int) -> Sequence: def read(path: pathlib.Path, identifier: str, count: int) -> Sequence:
document = generator.musicxml.load_musicxml(path) document = generator.musicxml.load_musicxml(path)
+39 -1
View File
@@ -32,6 +32,19 @@ class Note:
sources: tuple[str, ...] = () sources: tuple[str, ...] = ()
role: str = 'continuation' role: str = 'continuation'
position: generator.fretboard.TabPosition | None = None position: generator.fretboard.TabPosition | None = None
continuation: bool = False
def sounding_notes(notes):
"""Collapse explicitly continued attacks, never adjacent repeated pitches."""
result = []
for note in notes:
if note.continuation:
if not result or result[-1].end != note.start or result[-1].pitch != note.pitch or result[-1].position != note.position:
raise ValueError('solo continuation requires the same contiguous pitch and guitar position')
result[-1] = dataclasses.replace(result[-1], end=note.end)
else:
result.append(note)
return tuple(result)
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Motif: class Motif:
@@ -51,6 +64,23 @@ class TargetEvent:
chord: str chord: str
reason: str reason: str
@dataclasses.dataclass(frozen=True)
class Passage:
start: fractions.Fraction
end: fractions.Fraction
intent: str
activity: fractions.Fraction
occupancy: fractions.Fraction
@dataclasses.dataclass(frozen=True)
class Landmark:
earliest: fractions.Fraction
latest: fractions.Fraction
low: int
high: int
direction: int = 0
name: str = 'arrival'
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class PhrasePlan: class PhrasePlan:
id: str id: str
@@ -63,6 +93,11 @@ class PhrasePlan:
register: tuple[int, int] register: tuple[int, int]
activity: fractions.Fraction activity: fractions.Fraction
release: fractions.Fraction = fractions.Fraction(1, 2) release: fractions.Fraction = fractions.Fraction(1, 2)
passages: tuple[Passage, ...] = ()
landmarks: tuple[Landmark, ...] = ()
ending: str = 'breath'
closure: str = 'closed'
seed: str = ''
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Decision: class Decision:
@@ -71,11 +106,13 @@ class Decision:
source: str source: str
treatment: str treatment: str
notes: tuple[Note, ...] notes: tuple[Note, ...]
fidelity: float = 0
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Phrase: class Phrase:
plan: PhrasePlan plan: PhrasePlan
decisions: tuple[Decision, ...] decisions: tuple[Decision, ...]
score: float = 0
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Solo: class Solo:
@@ -84,8 +121,9 @@ class Solo:
phrases: tuple[Phrase, ...] phrases: tuple[Phrase, ...]
tempo: fractions.Fraction tempo: fractions.Fraction
end: fractions.Fraction end: fractions.Fraction
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
def notes(self) -> tuple[Note, ...]: def notes(self) -> tuple[Note, ...]:
return tuple(n for p in self.phrases for d in p.decisions for n in d.notes) return sounding_notes(n for p in self.phrases for d in p.decisions for n in d.notes)
@functools.lru_cache(maxsize=4096) @functools.lru_cache(maxsize=4096)
def scale_pitch(scale: generator.scales.library.Scale, index: int, octave: int = 4) -> Pitch: def scale_pitch(scale: generator.scales.library.Scale, index: int, octave: int = 4) -> Pitch:
+6 -2
View File
@@ -117,13 +117,17 @@ class SoloTests(unittest.TestCase):
result = compose(source) result = compose(source)
self.assertLessEqual(len({p.plan.motif.id for p in result.phrases}), 3) self.assertLessEqual(len({p.plan.motif.id for p in result.phrases}), 3)
self.assertEqual(result.phrases[0].plan.motif.id, result.phrases[1].plan.motif.id) self.assertEqual(result.phrases[0].plan.motif.id, result.phrases[1].plan.motif.id)
first = result.phrases[0] 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)
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')) quote = next(d for d in first.decisions if d.treatment.startswith('quote'))
self.assertEqual(len(quote.notes), len(first.plan.motif.notes)) self.assertEqual(len(quote.notes), len(first.plan.motif.notes))
shifts = {a.pitch.number() - b.pitch.number() for a, b in zip(quote.notes, first.plan.motif.notes)} shifts = {a.pitch.number() - b.pitch.number() for a, b in zip(quote.notes, first.plan.motif.notes)}
self.assertEqual(len(shifts), 1) self.assertEqual(len(shifts), 1)
self.assertEqual(next(iter(shifts)) % 12, 0) self.assertEqual(next(iter(shifts)) % 12, 0)
self.assertEqual([(n.start, n.end) for n in quote.notes], [(first.plan.start + n.start, first.plan.start + n.end) for n in first.plan.motif.notes]) self.assertEqual([(n.start, n.end) for n in quote.notes], [(first.plan.start + n.start, first.plan.start + n.end) for n in first.plan.motif.notes])
result = dataclasses.replace(result, phrases=(first, *result.phrases[1:]))
report = generator.solo.report.describe(result, tests.scale_fixtures.library()) report = generator.solo.report.describe(result, tests.scale_fixtures.library())
self.assertIn('quote octave', report) self.assertIn('quote octave', report)
self.assertIn('source events:', report) self.assertIn('source events:', report)
@@ -140,7 +144,7 @@ class SoloTests(unittest.TestCase):
result = compose(source) result = compose(source)
self.assertTrue(result.notes()) self.assertTrue(result.notes())
self.assertFalse(any(n.start < 2 and n.end > 1 for n in result.notes())) self.assertFalse(any(n.start < 2 and n.end > 1 for n in result.notes()))
self.assertTrue(any(d.treatment == 'fragment' for p in result.phrases for d in p.decisions)) self.assertTrue(any(c.scale is None and c.span.start == 1 and c.span.end == 2 for c in result.choices))
def test_microtones_are_not_rounded_into_the_catalog(self): def test_microtones_are_not_rounded_into_the_catalog(self):
with self.assertRaisesRegex(ValueError, 'semitones'): with self.assertRaisesRegex(ValueError, 'semitones'):
generator.solo.types.Pitch('c', fractions.Fraction(1, 2), 4) generator.solo.types.Pitch('c', fractions.Fraction(1, 2), 4)
+167
View File
@@ -0,0 +1,167 @@
import dataclasses
import fractions
import pathlib
import unittest
import generator.harmony
import generator.model
import generator.rhythm
import generator.solo.planning
import generator.solo.analysis
import generator.solo.composer
import generator.solo.placement
import generator.solo.realization
import generator.solo.scoring
import generator.solo.types
import generator.solo.sequences
import generator.fretboard
import generator.scales.selection
import generator.scales.timeline
import tests.scale_fixtures
import tests.solo_fixtures
import tests.rhythm_fixtures
import tests.test_solo
def choices(root='C', fifths=0, end=8):
scale = tests.scale_fixtures.library().scale('major', 1, root)
span = generator.scales.timeline.ChordSpan(fractions.Fraction(), fractions.Fraction(end), 1, '1', fractions.Fraction(1), generator.model.key_from_fifths(fifths), generator.harmony.parse_symbol(root + 'M7'))
return tuple([generator.scales.selection.Choice(span, scale, 'fixture')])
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())
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):
return generator.solo.types.Note(fractions.Fraction(start), fractions.Fraction(end), generator.solo.types.Pitch(letter, fractions.Fraction(alter), octave), **kwargs)
class ScoringTests(unittest.TestCase):
def test_silence_is_an_intent_not_a_register_advantage(self):
for root, fifths, pitch in (('C', 0, ('g', 0)), ('Db', -5, ('a', -1))):
harmony = choices(root, fifths)
passage = plan()
notes = tuple(note(i, i + 1, *pitch) for i in range(8))
self.assertFalse(generator.solo.scoring.feasible(passage, (), harmony, 8))
self.assertTrue(generator.solo.scoring.feasible(passage, notes, harmony, 8))
sounded = generator.solo.scoring.value(generator.solo.scoring.features(passage, notes, harmony))
silent = generator.solo.scoring.value(generator.solo.scoring.features(passage, (), harmony))
self.assertLess(sounded, silent)
self.assertEqual(generator.solo.scoring.features(passage, notes, harmony)['register'], 0)
def test_breath_sustain_and_activity_have_different_obligations(self):
harmony = choices()
self.assertTrue(generator.solo.scoring.feasible(plan('space'), (), harmony, 8))
self.assertFalse(generator.solo.scoring.feasible(plan('space'), (note(0, 8),), harmony, 8))
sustained = generator.solo.scoring.features(plan('sustained_arrival'), (note(0, 8),), harmony)
active = generator.solo.scoring.features(plan(), (note(0, 8),), harmony)
self.assertEqual(sustained['occupancy'], 0)
self.assertEqual(sustained['activity'], 0)
self.assertGreater(active['activity'], sustained['activity'])
self.assertTrue(generator.solo.scoring.feasible(plan(), (), harmony, 4))
def test_partition_and_ties_do_not_change_musical_evaluation(self):
whole = (note(0, 4), note(4, 8, 'a'))
split = (note(0, 2), note(2, 4, continuation=True), note(4, 6, 'a'), note(6, 8, 'a', continuation=True))
self.assertEqual(generator.solo.scoring.features(plan(), whole, choices()), generator.solo.scoring.features(plan(), split, choices()))
self.assertEqual(len(generator.solo.types.sounding_notes(split)), 2)
self.assertEqual(len(generator.solo.types.sounding_notes((note(0, 2), note(2, 4)))), 2)
def test_transposition_moves_corridor_and_notes_together(self):
original = (note(0, 2, 'c'), note(2, 4, 'e'), note(4, 8, 'g'))
moved = (note(0, 2, 'd'), note(2, 4, 'f', 1), note(4, 8, 'a'))
shifted = dataclasses.replace(plan(), register=(61, 77))
self.assertEqual(generator.solo.scoring.features(plan(), original, choices()), generator.solo.scoring.features(shifted, moved, choices('D', 2)))
def test_no_harmony_removes_activity_obligations(self):
harmony = tuple(dataclasses.replace(c, scale=None, span=dataclasses.replace(c.span, chord=None)) for c in choices())
self.assertTrue(generator.solo.scoring.feasible(plan(), (), harmony, 8))
self.assertEqual(generator.solo.scoring.value(generator.solo.scoring.features(plan(), (), harmony)), 0)
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'))
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)
self.assertTrue(any(a.end == b.start == 4 and a.end - a.start == b.end - b.start == fractions.Fraction(1, 2) for a, b in zip(notes, notes[1:])))
self.assertTrue(any(n.start == 8 and n.pitch.number() % 12 == 4 and n.end - n.start >= 4 for n in notes))
self.assertEqual(notes[-1].end, 16)
def test_exact_quote_cannot_silently_fragment_at_no_harmony(self):
source = tests.test_solo.song(count=2, chords=('CM7',))
passage = dataclasses.replace(plan(), treatment='exact_quote')
harmony = tuple(dataclasses.replace(c, scale=None, span=dataclasses.replace(c.span, chord=None)) for c in choices())
with self.assertRaises(generator.solo.realization.SearchExhausted):
generator.solo.realization.compose(passage, source['staves'][0]['measures'], harmony, tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), fractions.Fraction(120))
def test_dense_transformed_motif_can_fragment_at_no_harmony(self):
harmony = choices()
active = dataclasses.replace(harmony[0], span=dataclasses.replace(harmony[0].span, end=fractions.Fraction(3)))
silence = dataclasses.replace(harmony[0], scale=None, span=dataclasses.replace(harmony[0].span, start=fractions.Fraction(3), chord=None))
passage = dataclasses.replace(plan(), treatment='contour_reference')
spans = tuple((fractions.Fraction(i, 2), fractions.Fraction(i + 1, 2)) for i in range(8))
cell = generator.solo.realization.Cell(fractions.Fraction(), fractions.Fraction(4), (('eighths', spans),), passage.motif)
candidates = generator.solo.realization.candidates(cell, passage, (active, silence), tests.solo_fixtures.library())
self.assertTrue(candidates)
self.assertTrue(all(len(c.notes) == 6 and all(n.end <= 3 for n in c.notes) for c in candidates))
def test_dense_rhythms_and_chained_paths_retain_authored_timing(self):
# The real library's fully sounding eighths must survive retrieval.
rhythms = generator.rhythm.load_library(pathlib.Path('data/rhythms'))
patterns = generator.solo.realization.rhythm_patterns(rhythms, '4/4', fractions.Fraction(4), fractions.Fraction(2))
self.assertTrue(any(len(spans) == 8 and all(b - a == fractions.Fraction(1, 2) for a, b in spans) for _, spans in patterns))
sequences = tests.solo_fixtures.library()
lookup = {s.id: s for values in sequences.paths.values() for s in values}
paths = sequences.retrieve(8, 5, 1)
self.assertTrue(paths)
for path in paths:
self.assertEqual(len(path.offsets()), 8)
self.assertEqual(path.steps, tuple(step for source in path.sources for step in lookup[source].steps))
self.assertGreater(len({generator.solo.sequences.family(p.steps) for p in paths}), 2)
def test_retrieval_uses_paths_beyond_the_old_global_prefix(self):
library = tests.solo_fixtures.library()
original = {s.id for s in library.connecting(4)[:16]}
chosen = library.retrieve(4, 6, 1)
self.assertTrue(any(set(p.sources) - original for p in chosen))
self.assertTrue(any(p.offsets()[-1] == 6 for p in chosen))
self.assertEqual(chosen, library.retrieve(4, 6, 1))
def test_transformation_contracts_protect_different_features(self):
motif = generator.solo.types.Motif('source', (note(0, 1, 'c'), note(1, 2, 'd'), note(2, 3, 'e'), note(3, 4, 'c')), fractions.Fraction(4), ())
adapted = (note(0, 1, 'e'), note(1, 2, 'f'), note(2, 3, 'g'), note(3, 4, 'e'))
unrelated = (note(0, 1, 'g'), note(1, 2, 'f'), note(2, 3, 'e'), note(3, 4, 'g'))
self.assertEqual(generator.solo.scoring.motif_fidelity(unrelated, motif, 'rhythmic_reference'), 0)
self.assertLess(generator.solo.scoring.motif_fidelity(adapted, motif, 'contour_reference'), generator.solo.scoring.motif_fidelity(unrelated, motif, 'contour_reference'))
changed_ending = (*adapted[:3], note(3, 4, 'b'))
self.assertEqual(generator.solo.scoring.motif_fidelity(adapted, motif, 'opening_reference'), generator.solo.scoring.motif_fidelity(changed_ending, motif, 'opening_reference'))
def test_contextual_invention_and_identity_ignore_titles(self):
source = tests.test_solo.song(count=4, chords=('CM7',))
analysis = generator.solo.analysis.analyze(source)
seed = generator.solo.analysis.identity(analysis, source['staves'][0]['measures'], 'electric')
source['metadata']['title'] = 'Unrelated title'
self.assertEqual(seed, generator.solo.analysis.identity(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], 'electric'))
up = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4), choices=choices(), seed=seed)
changed = generator.solo.planning.new_motif(tests.solo_fixtures.library(), fractions.Fraction(4), choices=choices('D', 2), seed=seed, variant=1)
self.assertNotEqual(up.contour(), changed.contour())
def test_continuation_keeps_fret_position_and_one_attack(self):
previous = note(0, 4, position=generator.fretboard.TabPosition(2, 8, 67))
following = note(4, 6, continuation=True)
placed = generator.solo.placement.place((following,), fractions.Fraction(120), previous)
self.assertEqual(placed[0], 0)
self.assertEqual(placed[1][0].position, previous.position)
joined = generator.solo.types.sounding_notes((previous, *placed[1]))
self.assertEqual([(n.start, n.end) for n in joined], [(0, 6)])
def test_same_ending_does_not_merge_different_gestures(self):
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))
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')
high = generator.solo.types.Phrase(first, (generator.solo.types.Decision(0, 4, 'fixture', 'invented', (note(0, 4, 'g', octave=5),)),), 0)
low = generator.solo.types.Phrase(first, (generator.solo.types.Decision(0, 4, 'fixture', 'invented', (note(0, 4, 'c'),)),), 0.1)
answer = generator.solo.types.Phrase(second, (generator.solo.types.Decision(4, 8, 'fixture', 'answer', (note(4, 8, 'd'),)),), 0)
selected = generator.solo.composer.choose_phrases((first, second), lambda p, previous: [high, low] if p.id == first.id else [answer])
self.assertEqual(selected[0], low)
def test_committed_statement_still_affects_final_answer_selection(self):
first = dataclasses.replace(plan(), end=fractions.Fraction(4), ending='connected')
second = dataclasses.replace(plan(), id='answer', start=fractions.Fraction(4), relationship='answer')
statement = generator.solo.types.Phrase(first, (generator.solo.types.Decision(0, 4, 'fixture', 'invented', (note(0, 4, 'c'),)),), 0)
far = generator.solo.types.Phrase(second, (generator.solo.types.Decision(4, 8, 'fixture', 'answer', (note(4, 8, 'g', octave=5),)),), 0)
near = generator.solo.types.Phrase(second, (generator.solo.types.Decision(4, 8, 'fixture', 'answer', (note(4, 8, 'd'),)),), 0.1)
selected = generator.solo.composer.choose_phrases((first, second), lambda p, previous: [statement] if p.id == first.id else [far, near])
self.assertEqual(selected[-1], near)