@@ -99,10 +99,14 @@ Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live
|
||||
|
||||
Solo harmonic function is distinct from scale permission. Basic members respect the chord's suspensions, alterations and omissions; symbol-specified extensions require a planned color when sustained, and a non-chord slash bass is not automatically a melodic destination. Ordinary notes lasting two quarter-note beats or longer must remain chord members across every overlapping chord, even when the scale stays unchanged. Short scale motion remains available. A prepared suspension must resolve by the planned deadline to an adjacent chord member. These checks operate on complete sounding events, so notation ties do not change harmonic treatment or attack counts. Protected quotations keep their musical content and report exposed tensions.
|
||||
|
||||
Concrete continuations start from the actual incoming pitch and guitar position. Relative routes from `data/sequences` use exact octave-aware scale indices; a collection change adapts actual pitches without transferring stale degree numbers. Realized statements supply subsequent rhythm, contour, harmonic-anchor and transformation history. Intermediate arrivals permit continuation; terminal arrivals must meet their duration and ending contract. Activity and occupancy targets remain separate from these harmonic requirements. Search budgets remain 64 states, 32 extensions and four retained alternatives, with a rolling gesture window and at most two explicit development-contract replans. Exhaustion reports the failed continuation or contract, not a claim of musical impossibility.
|
||||
The composer builds complete timed melodic drafts before selecting elaboration. An identifying rhythm is repeated, extended, displaced, fragmented or answered across the phrase, including across barlines. Extensions can reopen provisional endings; committed anchors and protected quotations remain binding. The original realized home motif and its latest development are retained separately, including into later passages, where continuation, recall and contrast are explicit choices. Several anchor contours and terminal durations are considered with their rhythmic realization. Bounded revision can edit both sides of an internal join, followed by complete harmonic, motif, timing and guitar validation.
|
||||
|
||||
Concrete continuations start from the actual incoming pitch and guitar position. Relative routes from `data/sequences` use exact octave-aware scale indices; a collection change adapts actual pitches without transferring stale degree numbers. Intermediate arrivals permit continuation; terminal arrivals must meet their duration and ending contract. Activity and occupancy targets remain unchanged. Search retains up to 64 pitch states within a musical group, 16 at completed groups, and eight complete phrase alternatives. Join revision considers up to eight proposals in two-beat windows on either side, with at most two sweeps. One-phrase lookahead also permits revisions across an uncommitted connected phrase seam. Up to two further construction batches may be tried without weakening musical contracts. Exhaustion reports the failed continuation or contract, not a claim of musical impossibility.
|
||||
|
||||
For reproducible listening comparisons, `python -m generator.solo.audition --output build/solo_passage_auditions --capture before` captures the previous implementation; run the corresponding `--capture after` after changing the engine, then `--render`. Use the repository's pinned Python runner. The output includes full solos, opening eight-bar and closing four-bar excerpts, MusicXML, passage reports and structured diagnostics. Fixed-backing comparisons keep the original accompaniment; responsive comparisons use the newly arranged accompaniment. These build-only tools do not add runtime data or playback expression.
|
||||
|
||||
For phrase-draft experiments, preserve a `--capture baseline` snapshot before changing the implementation, then run `--compare-phrases` and `--render` in that output directory. This compares complete phrase construction with and without join revision, sharing concrete draft pools when incoming musical states match. It also exports simple drafts, neutral-pitch rhythm diagnostics, motif lineage, selected edits and ending completion. Earlier revisions can change later incoming states and therefore require different draft pools. Capture timings include cache effects; normal production builds initialize their own search. Listening and playing, rather than the diagnostics alone, determine whether the result is better.
|
||||
|
||||
To inspect generated assets without building the full package, run:
|
||||
|
||||
```sh
|
||||
@@ -116,7 +120,7 @@ CMake builds engrave with two Python workers and share a disposable page cache u
|
||||
|
||||
Identical notation bodies share layout and body rasterization even when titles or score identities differ; each score retains its own header, pages, music, and catalog identity. A `cold namespace` means no cache directory exists for the current renderer fingerprint. It does not mean that every score must be rendered independently: repeated bodies can still be reused within that build, including with persistent caching disabled. Progress separates full-score hits, body hits, newly rasterized bodies, and composed pages.
|
||||
|
||||
The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model and rhythm plans -> part generation -> JSON, engraved PNG pages, generated_tone audio, Pico title artwork, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Songs generate Acoustic, Electric, and Vocal parts in their authored key, without key variants or a key-selection menu.
|
||||
The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model and rhythm plans -> part generation -> JSON, engraved PNG pages, generated_tone audio, Pico title artwork, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Songs generate Acoustic, Electric, and Vocal parts in their authored key, without key variants or a key-selection menu. Measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure, and exported excerpts retain their arrangement measure labels.
|
||||
|
||||
Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats with a one-beat variation in the fourth bar when a suitable candidate exists. Acoustic has independently planned bass and upper-chord rhythms, including simultaneous attacks, sharing one fixed playable voicing until the harmony changes within an arrangement iteration. Bass, upper voices, and TAB positions stay constant across rests, barlines, and repeated chord symbols. Any defined chord tone can serve as the bass; explicit slash basses and inversions remain authoritative. The rhythms combine into one notated voice with no ties: each attack releases by the next attack or barline, simultaneous tones share a stem and duration, and rests appear only when the entire part is silent. Held notes are not reattacked. Electric accompaniment targets about two attacks per 4/4 bar, scaled for other meters, favoring gaps around the foreground. Its notes retain the selected rhythm's duration without a one-beat cap, stopping at chord changes or barlines and reusing the corresponding melody passage's chord shape when available and scale-compatible. Both parts remain tie-free; clipped binary durations that need ties shorten to one conventional note followed by silence. Rhythm snippets supply attacks and rests; tied continuations do not create accompaniment attacks. Structured harmony supplies chord tones. N.C. and absent harmony remain silent. The seven-section form is unchanged; soloing guitars play single-note phrases without self-accompaniment. Unsupported accompaniment meters and mid-measure meter changes fail explicitly.
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
|
||||
if generator.accompaniment.connect_continuations(result):
|
||||
generator.guitar.place_guitar(result)
|
||||
generator.electric.reuse_voicings(voicing_links)
|
||||
# Source labels identify the head while arranging; published labels identify
|
||||
# the complete performance, including its lead-in and repeated choruses.
|
||||
for number, measure in enumerate(output, 1):
|
||||
measure['number'] = str(number)
|
||||
generator.fretboard.validate_document(result)
|
||||
if scales and solos:
|
||||
report = generator.scales.mapping.annotate(result, solos, scales, source_name or document['metadata']['title'])
|
||||
|
||||
@@ -241,7 +241,7 @@ def musicxml_tree(document: dict) -> xml.etree.ElementTree.ElementTree:
|
||||
absolute = fractions.Fraction()
|
||||
previous_state = {}
|
||||
for index, measure in enumerate(staff['measures']):
|
||||
bar = element(part, 'measure', number=index + 1, implicit='yes' if measure['implicit'] else 'no')
|
||||
bar = element(part, 'measure', number=measure['number'], implicit='yes' if measure['implicit'] else 'no')
|
||||
write_barline(bar, measure, 'left')
|
||||
times = [generator.model.fraction(measure['duration'])]
|
||||
times.extend(generator.model.fraction(n[field]) for v in measure['voices'] for n in v['notes'] for field in ('onset', 'duration'))
|
||||
|
||||
@@ -8,13 +8,13 @@ def chord_notes(chord: dict) -> tuple[tuple[int, str], ...]:
|
||||
return tuple((degree, generator.model.pitch_name(generator.pitch.shift_pitch({**chord['root'], 'octave': 4}, degree - 1, generator.harmony.degree_semitones(degree, alter)))) for degree, alter in generator.harmony.degrees(chord).items())
|
||||
|
||||
def passage(part: str, solo, choices, library) -> str:
|
||||
lines = [f'{part.capitalize()}: {solo.label}', 'Positions use source measure numbers and quarter-note beats; arranged measure numbers are also shown.', '']
|
||||
lines = [f'{part.capitalize()}: {solo.label}', 'Positions use the score’s measure labels and quarter-note beats; sequential measure indices are also shown.', '']
|
||||
lines.append('Scale map:')
|
||||
previous = None
|
||||
for choice in choices:
|
||||
label = choice.scale.label() if choice.scale else 'No exact match within the permitted catalog.' if choice.span.chord else 'N.C.'
|
||||
if label != previous:
|
||||
lines.append(f' Source measure {choice.span.source_measure}, beat {choice.span.beat}: {label}')
|
||||
lines.append(f' Measure {choice.span.source_measure}, beat {choice.span.beat}: {label}')
|
||||
lines.append(' ' + choice.reason)
|
||||
previous = label
|
||||
lines.extend(('', 'Chord detail:'))
|
||||
@@ -28,7 +28,7 @@ def passage(part: str, solo, choices, library) -> str:
|
||||
if bass := chord.get('bass'):
|
||||
required.append(generator.model.pitch_name(bass))
|
||||
targets = [name for degree, name in notes if degree in (3, 7)] or [name for _, name in notes]
|
||||
lines.append(f'Source measure {span.source_measure}, beat {span.beat}; arranged measure {span.measure}; offset {span.start} through {span.end}')
|
||||
lines.append(f'Measure {span.source_measure}, beat {span.beat}; sequential measure {span.measure}; offset {span.start} through {span.end}')
|
||||
lines.append(' Chord: ' + generator.harmony.symbol(chord) + '; required pitches: ' + ', '.join(dict.fromkeys(required)))
|
||||
if scale:
|
||||
root = generator.model.pitch_number({**chord['root'], 'octave': 4}) % 12
|
||||
|
||||
@@ -85,7 +85,7 @@ def backing(baseline, foreground, part, rhythms, references, choices, initial=No
|
||||
generator.model.validate_document(document)
|
||||
return document['staves'][0]['measures']
|
||||
|
||||
def apply(source, parts, *, rhythms, scales, sequences, solos=None):
|
||||
def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None):
|
||||
"""Compose once per song, replacing only already-placed solo section windows."""
|
||||
analysis = generator.solo.analysis.analyze(source)
|
||||
sections = regions(source)
|
||||
@@ -103,7 +103,7 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None):
|
||||
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']]
|
||||
if sounding:
|
||||
cutoff -= generator.model.fraction(pickup['duration']) - min(sounding)
|
||||
solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial)
|
||||
solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial, revise=revise, context=(contexts or {}).get(part))
|
||||
if solos is not None:
|
||||
solos.append(solo)
|
||||
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
|
||||
|
||||
+88
-10
@@ -5,6 +5,7 @@ import argparse
|
||||
import array
|
||||
import collections
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import hashlib
|
||||
import html
|
||||
@@ -26,6 +27,7 @@ import generator.scales.library
|
||||
import generator.solo.arrangement
|
||||
import generator.solo.report
|
||||
import generator.solo.sequences
|
||||
import generator.solo.realization
|
||||
|
||||
STAGES = ('baseline', 'A', 'B', 'C', 'D')
|
||||
|
||||
@@ -71,13 +73,14 @@ def fingerprint():
|
||||
digest.update(path.read_bytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
def capture(root, stage):
|
||||
def resources():
|
||||
return (generator.rhythm.load_library(pathlib.Path('data/rhythms')), generator.scales.library.load_library(pathlib.Path('data/scales')), generator.solo.sequences.load_library(pathlib.Path('data/sequences')))
|
||||
|
||||
def capture(root, stage, libraries=None, contexts=None):
|
||||
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'))
|
||||
rhythms, scales, sequences = libraries or resources()
|
||||
result = {'stage': stage, 'implementation': fingerprint(), 'songs': {}}
|
||||
try:
|
||||
result['policy'] = importlib.import_module('generator.solo.policy').describe()
|
||||
@@ -89,12 +92,37 @@ def capture(root, stage):
|
||||
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)
|
||||
shared = contexts.setdefault(path.stem, {p: generator.solo.realization.SearchContext() for p in ('acoustic', 'electric')}) if contexts is not None else None
|
||||
if shared:
|
||||
for context in shared.values():
|
||||
context.diagnostics = generator.solo.realization.SearchContext().diagnostics
|
||||
generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared)
|
||||
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), 'structure': generator.solo.report.structure(solo)}
|
||||
drafts = []
|
||||
template = parts[solo.part]['staves'][0]['measures'][first:first + count]
|
||||
boundaries = [fractions.Fraction()]
|
||||
for measure in template:
|
||||
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
|
||||
for phrase in solo.phrases:
|
||||
if not phrase.draft:
|
||||
continue
|
||||
begin = boundaries.index(phrase.plan.start)
|
||||
stop = next(i for i, boundary in enumerate(boundaries) if boundary >= phrase.plan.end)
|
||||
view = excerpt(parts[solo.part], first + begin, stop - begin)
|
||||
local = tuple(dataclasses.replace(n, start=n.start - phrase.plan.start, end=n.end - phrase.plan.start, continuation=False if i == 0 else n.continuation) for i, n in enumerate(phrase.draft.notes))
|
||||
placed = generator.solo.placement.place(local, solo.tempo)
|
||||
if placed is None:
|
||||
raise ValueError(f'{phrase.plan.id}: draft cannot be placed for independent audition')
|
||||
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], 'draft_' + phrase.plan.id)
|
||||
generator.solo.notation.replace_region(view, 0, foreground)
|
||||
generator.model.validate_document(view)
|
||||
generator.fretboard.validate_document(view)
|
||||
drafts.append({'id': phrase.plan.id, 'draft_id': phrase.draft.id, 'first': begin, 'count': stop - begin, 'document': view})
|
||||
song['solos'][solo.part]['draft_documents'] = drafts
|
||||
print(path.stem, solo.part, json.dumps(song['solos'][solo.part]['metrics']), flush=True)
|
||||
result['songs'][path.stem] = song
|
||||
destination.mkdir(parents=True)
|
||||
@@ -152,7 +180,7 @@ def mix(documents, tempo, root, cache):
|
||||
return output
|
||||
|
||||
def render(root):
|
||||
stages = ('before', 'after') if (root / 'before' / 'snapshot.json').exists() else STAGES
|
||||
stages = ('baseline', 'phrase', 'revised') if (root / 'revised' / 'snapshot.json').exists() else ('before', 'after') if (root / 'before' / 'snapshot.json').exists() else STAGES
|
||||
snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in stages}
|
||||
cache, clips, manifest = {}, [], {'sample_rate': generator.playback.SAMPLE_RATE, 'stages': {}, 'clips': []}
|
||||
for stage, snapshot in snapshots.items():
|
||||
@@ -163,7 +191,7 @@ def render(root):
|
||||
raise ValueError(f'{song_name}: source changed between audition stages')
|
||||
for part, solo in song['solos'].items():
|
||||
other = 'electric' if part == 'acoustic' else 'acoustic'
|
||||
windows = [('full', 0, solo['count']), ('opening8', 0, min(8, solo['count'])), ('closing4', max(0, solo['count'] - 4), min(4, solo['count']))] if stages[0] == 'before' else [('', 0, min(8, solo['count']))]
|
||||
windows = [('full', 0, solo['count']), ('opening8', 0, min(8, solo['count'])), ('closing4', max(0, solo['count'] - 4), min(4, solo['count']))] if stages[0] == 'before' or stages[-1] == 'revised' else [('', 0, min(8, solo['count']))]
|
||||
for label, offset, count in windows:
|
||||
first = solo['first'] + offset
|
||||
foreground = excerpt(song['parts'][part], first, count)
|
||||
@@ -179,6 +207,24 @@ def render(root):
|
||||
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
|
||||
clips.append((path, samples))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'window': label, 'first': offset, 'count': count, 'tempo': solo['tempo'], 'responsive_backing': responsive, 'source_sha256': song['source_sha256'], 'metrics': solo['metrics'], 'elapsed_seconds': song['elapsed_seconds']})
|
||||
for draft in solo.get('draft_documents', ()):
|
||||
prefix = root / stage / f'{song_name}_{part}_draft_{draft["id"]}'
|
||||
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), draft['document'])
|
||||
if draft['first'] < 8:
|
||||
backing = excerpt(baseline['parts'][other], solo['first'] + draft['first'], draft['count'])
|
||||
path = prefix.with_suffix('.wav')
|
||||
clips.append((path, mix((draft['document'], backing), fractions.Fraction(solo['tempo']), root, cache)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'simple_phrase_draft', 'draft_id': draft['draft_id'], 'first': draft['first'], 'count': draft['count']})
|
||||
if stage in ('phrase', 'revised'):
|
||||
neutral = excerpt(song['parts'][part], solo['first'], min(8, solo['count']))
|
||||
for measure in neutral['staves'][0]['measures']:
|
||||
for voice in measure['voices']:
|
||||
for event in voice['notes']:
|
||||
for pitch in event['pitches']:
|
||||
pitch.update({'letter': 'c', 'alter': generator.model.rational(0), 'octave': 5, 'tablature': {'string': 2, 'fret': 1}})
|
||||
path = root / stage / f'{song_name}_{part}_rhythm_only.wav'
|
||||
clips.append((path, mix((neutral,), fractions.Fraction(solo['tempo']), root, cache)))
|
||||
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'rhythm_only_diagnostic', 'valid_harmonic_solo': False})
|
||||
# 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
|
||||
@@ -186,9 +232,33 @@ def render(root):
|
||||
for path, samples in clips:
|
||||
generator.playback.write_pcm(path, (value * gain for value in samples))
|
||||
(root / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
write_passage_index(root, snapshots) if stages[0] == 'before' else write_index(root, snapshots)
|
||||
write_phrase_index(root, snapshots) if stages[-1] == 'revised' else write_passage_index(root, snapshots) if stages[0] == 'before' else write_index(root, snapshots)
|
||||
print(f'Rendered {len(clips)} comparisons under {root}', flush=True)
|
||||
|
||||
def write_phrase_index(root, snapshots):
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Melodic phrase comparisons</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin-bottom:2rem}td,th{text-align:left;padding:.7rem;border-bottom:1px solid #ccd1c9}audio{width:250px}small{display:block}</style><h1>Melodic phrase comparisons</h1><p>Compare the current baseline, complete phrase construction, and phrase construction with two-sided join revision. Tempo, scale policy, activity targets and seed inputs are unchanged. Fixed mixes use baseline accompaniment and one shared gain. Revised mixes also include the normal responsive backing.</p><p>Draft pools are shared when incoming musical states match; earlier edits can legitimately change later incoming states. Pool fingerprints are recorded in the reports. Simple drafts are auditioned individually, with a held entrance restarted at the excerpt boundary. Rhythm-only clips use a neutral pitch and are not valid harmonic solos.</p><p>Listen for one recognizable idea developing toward a prepared destination. Metrics do not establish musical improvement. <a href="manifest.json">Manifest and diagnostics</a>.</p>']
|
||||
for name, song in snapshots['revised']['songs'].items():
|
||||
for part in song['solos']:
|
||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2>')
|
||||
body.append('<table><tr><th>Excerpt</th><th>Baseline</th><th>Phrase construction</th><th>With join revision</th></tr>')
|
||||
for window in ('opening8', 'full', 'closing4'):
|
||||
body.append(f'<tr><th>{window}</th>')
|
||||
for stage in ('baseline', 'phrase', 'revised'):
|
||||
prefix = f'{stage}/{name}_{part}_{window}'
|
||||
body.append(f'<td><audio controls preload="none" src="{prefix}_fixed.wav"></audio><small><a href="{prefix}.musicxml">MusicXML</a> · <a href="{prefix}.txt">Report</a> · <a href="{prefix}.json">Structure</a></small></td>')
|
||||
body.append('</tr>')
|
||||
body.append('</table>')
|
||||
for stage in ('phrase', 'revised'):
|
||||
detail = snapshots[stage]['songs'][name]['solos'][part]
|
||||
metric = detail['metrics']
|
||||
body.append(f'<p>{stage}: {metric["attacks_per_four_beats"]:.2f} attacks / four beats; {metric["sounding_occupancy"]:.1%} occupancy. Rhythm only: <audio controls preload="none" src="{stage}/{name}_{part}_rhythm_only.wav"></audio></p>')
|
||||
for draft in detail.get('draft_documents', ()):
|
||||
if draft['first'] < 8:
|
||||
prefix = f'{stage}/{name}_{part}_draft_{draft["id"]}'
|
||||
body.append(f'<p>Simple draft {draft["id"]}: <audio controls preload="none" src="{prefix}.wav"></audio> <a href="{prefix}.musicxml">MusicXML</a></p>')
|
||||
body.append(f'<p>Complete solo with responsive backing: <audio controls preload="none" src="revised/{name}_{part}_full_responsive.wav"></audio></p>')
|
||||
(root / 'index.html').write_text(''.join(body) + '</html>\n')
|
||||
|
||||
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>']
|
||||
@@ -225,10 +295,18 @@ 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, 'before', 'after'))
|
||||
action.add_argument('--capture', choices=(*STAGES, 'before', 'after', 'phrase', 'revised'))
|
||||
action.add_argument('--compare-phrases', action='store_true', help='capture both new stages using shared concrete draft pools')
|
||||
action.add_argument('--render', action='store_true')
|
||||
args = parser.parse_args()
|
||||
capture(args.output, args.capture) if args.capture else render(args.output)
|
||||
if args.compare_phrases:
|
||||
libraries, contexts = resources(), {}
|
||||
capture(args.output, 'phrase', libraries, contexts)
|
||||
capture(args.output, 'revised', libraries, contexts)
|
||||
elif args.capture:
|
||||
capture(args.output, args.capture)
|
||||
else:
|
||||
render(args.output)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
+16
-18
@@ -15,7 +15,7 @@ import generator.solo.policy
|
||||
import generator.solo.realization
|
||||
import generator.solo.types
|
||||
|
||||
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None):
|
||||
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None):
|
||||
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
|
||||
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
|
||||
choices = generator.scales.selection.select(timeline, region, scales)
|
||||
@@ -29,27 +29,21 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
|
||||
if not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
|
||||
raise ValueError(f'{part} solo: protected quotation has no exact compatible scale collection')
|
||||
choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in choices)
|
||||
context = generator.solo.realization.SearchContext()
|
||||
context = context if context is not None else generator.solo.realization.SearchContext()
|
||||
context.revise = revise
|
||||
|
||||
def realize(plan, previous):
|
||||
original = plan
|
||||
for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1):
|
||||
context.batch = attempt
|
||||
try:
|
||||
options = generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context)
|
||||
if attempt:
|
||||
context.results[(original, previous)] = options
|
||||
return options
|
||||
except generator.solo.realization.SearchExhausted as error:
|
||||
if attempt == generator.solo.policy.DEFAULT.replan_attempts or plan.treatment in ('quote', 'exact_quote'):
|
||||
raise
|
||||
context.diagnostics.setdefault('replanning', []).append({'phrase': plan.id, 'attempt': attempt + 1, 'reason': str(error), 'adjustment': 'contour reference' if attempt == 0 else 'opening reference'})
|
||||
# Replan only ordinary development contracts; harmony, anchor pitches,
|
||||
# required holds, timing, activity and protected quotations stay fixed.
|
||||
treatment = 'contour_reference' if attempt == 0 else 'opening_reference'
|
||||
gestures = tuple(dataclasses.replace(g, treatment=treatment) if g.relationship not in ('statement', 'close') else g for g in plan.gestures)
|
||||
plan = dataclasses.replace(plan, gestures=gestures)
|
||||
context.diagnostics.setdefault('replanning', []).append({'phrase': plan.id, 'attempt': attempt + 1, 'reason': str(error), 'adjustment': 'next diverse complete rhythm and anchor constructions; contracts unchanged'})
|
||||
|
||||
phrases = choose_phrases(plans, realize)
|
||||
phrases = choose_phrases(plans, realize, lambda first, second, incoming: generator.solo.realization.revise_pair(first, second, incoming, choices, sequences, analysis.tempo, context))
|
||||
context.diagnostics['policy'] = generator.solo.policy.describe()['fingerprint']
|
||||
context.diagnostics['musical_identity'] = generator.solo.analysis.identity(analysis, measures, part)
|
||||
passage_ids = tuple(dict.fromkeys(p.passage_id for p in plans if p.passage_id))
|
||||
@@ -77,18 +71,20 @@ def transition_cost(first, second):
|
||||
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):
|
||||
def choose_phrases(plans, realize, revise_pair=None):
|
||||
"""Commit only after the next phrase has been evaluated from each ending."""
|
||||
result, previous = [], None
|
||||
result, previous, pending = [], None, None
|
||||
for index, plan in enumerate(plans):
|
||||
options = realize(plan, previous)
|
||||
if pending is not None and pending not in options:
|
||||
options = (pending, *options)
|
||||
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))
|
||||
ranked.append((option.score + incoming, option, None))
|
||||
continue
|
||||
try:
|
||||
following = realize(plans[index + 1], option.state or last)
|
||||
@@ -98,11 +94,13 @@ def choose_phrases(plans, realize):
|
||||
length = float(option.plan.end - option.plan.start)
|
||||
next_length = float(plans[index + 1].end - plans[index + 1].start)
|
||||
shared_passage = bool(plan.passage_id and plan.passage_id == plans[index + 1].passage_id)
|
||||
cost = min((future.score if shared_passage else (option.score * length + future.score * next_length) / (length + next_length)) + transition_cost(option, future) for future in following)
|
||||
ranked.append((cost + incoming, option))
|
||||
for future in following:
|
||||
left, right = revise_pair(option, future, previous) if revise_pair else (option, future)
|
||||
cost = (right.score if shared_passage else (left.score * length + right.score * next_length) / (length + next_length)) + transition_cost(left, right)
|
||||
ranked.append((cost + incoming, left, right))
|
||||
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]))))
|
||||
_, chosen, pending = 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 chosen.state:
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Phrase-wide rhythm construction and contracts for concrete melodic drafts.
|
||||
|
||||
No bar winners are assembled here. A rhythm is stated, transformed in place,
|
||||
and timed to its destinations before the pitch search begins.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
|
||||
import generator.model
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.policy
|
||||
import generator.solo.scoring
|
||||
import generator.solo.sequences
|
||||
import generator.solo.types
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Slot:
|
||||
id: str
|
||||
start: F
|
||||
end: F
|
||||
group: int
|
||||
reference: int
|
||||
forced: generator.solo.types.Pitch | None = None
|
||||
protected: bool = False
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Group:
|
||||
id: str
|
||||
operation: str
|
||||
start: F
|
||||
end: F
|
||||
source: str
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RhythmDraft:
|
||||
id: str
|
||||
slots: tuple[Slot, ...]
|
||||
groups: tuple[Group, ...]
|
||||
anchors: tuple[generator.solo.types.Anchor, ...]
|
||||
reference: generator.solo.types.Motif | generator.solo.types.RealizedMotif
|
||||
relationship: str
|
||||
octave: int = 0
|
||||
|
||||
def catalog(library, meter):
|
||||
"""Read complete snippets; ties across authored bars stay one attack."""
|
||||
result = {}
|
||||
for snippet in library.for_meter(meter):
|
||||
offset, spans = F(), []
|
||||
for bar in snippet.bars:
|
||||
for event in bar.events:
|
||||
if not event.sounding:
|
||||
continue
|
||||
start, end = offset + event.onset, offset + event.onset + event.duration
|
||||
if event.continuation:
|
||||
if spans and spans[-1][1] == start:
|
||||
spans[-1] = (spans[-1][0], end)
|
||||
else:
|
||||
spans.append((start, end))
|
||||
offset += bar.length
|
||||
if 2 <= len(spans) <= 12:
|
||||
result.setdefault(tuple(spans), (snippet.id, offset))
|
||||
return tuple((identifier, spans, length) for spans, (identifier, length) in result.items())
|
||||
|
||||
def templates(plan, reference, library, meter):
|
||||
source = tuple((n.start, n.end) for n in reference.notes)
|
||||
if plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
|
||||
return tuple([(reference.id, source, reference.duration)])
|
||||
# A compact identifying portion, including its authored internal rests.
|
||||
source = source[:6]
|
||||
result = []
|
||||
if source:
|
||||
length = min(reference.duration, source[-1][1] + F(1, 2))
|
||||
result.append((reference.id, source, length))
|
||||
choices = catalog(library, meter)
|
||||
ranked = generator.solo.sequences.diverse(choices,
|
||||
lambda item: (abs(len(item[1]) / float(item[2]) - float(plan.activity)), abs(sum(float(b - a) for a, b in item[1]) / float(item[2]) - 0.75), item[0]),
|
||||
lambda item: (len(item[1]), bool(item[1][0][0]), item[2]), 4)
|
||||
result.extend(ranked)
|
||||
return tuple(result)
|
||||
|
||||
def reopen(slots, cursor):
|
||||
"""An extension changes the previous provisional ending, not its core."""
|
||||
if not slots:
|
||||
return slots, cursor
|
||||
last = slots[-1]
|
||||
if last.protected or last.forced:
|
||||
return slots, cursor
|
||||
end = max(last.start + F(1, 2), last.end - F(1, 2))
|
||||
return (*slots[:-1], dataclasses.replace(last, end=end)), end
|
||||
|
||||
def develop_timing(pattern, period, unit, occupancy, connect):
|
||||
"""Diminish/broaden one established rhythm, retaining order and rests.
|
||||
|
||||
Ordinary diminution stops at eighth notes. Connected development may
|
||||
broaden existing sounds into part of their following rest, not insert a new
|
||||
independent pattern. These choices precede pitch/harmonic realization.
|
||||
"""
|
||||
result, cursor, previous = [], F(), F()
|
||||
for start, end in pattern:
|
||||
cursor += (start - previous) * unit
|
||||
duration = max(F(1, 2), (end - start) * unit) if unit < 1 else (end - start) * unit
|
||||
result.append((cursor, cursor + duration))
|
||||
cursor += duration
|
||||
previous = end
|
||||
length = cursor + (period - previous) * unit
|
||||
if connect:
|
||||
needed = max(F(), occupancy * length - sum(b - a for a, b in result))
|
||||
for i, (a, b) in enumerate(result):
|
||||
following = result[i + 1][0] if i + 1 < len(result) else length
|
||||
# Occupancy is a soft objective, not a duration denominator. Use a half
|
||||
# beat or the existing rest endpoint; never manufacture e.g. 7/40 beats
|
||||
# solely to hit a percentage exactly.
|
||||
extra = min(F(1, 2), following - b)
|
||||
if abs(needed - extra) >= needed:
|
||||
extra = F()
|
||||
result[i] = (a, b + extra)
|
||||
needed -= extra
|
||||
return tuple(result), length
|
||||
|
||||
def ending_options(anchors, plan, choices):
|
||||
if not anchors or plan.target or plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
|
||||
return tuple([anchors])
|
||||
terminal = next((a for a in anchors if a.role == 'terminal'), None)
|
||||
if terminal is None or terminal.hold_until is None:
|
||||
return tuple([anchors])
|
||||
finish = terminal.hold_until
|
||||
last = next((c for c in choices if c.span.start < finish <= c.span.end), None)
|
||||
result = []
|
||||
for duration in (F(1, 2), F(1), F(2), F(3)):
|
||||
start = finish - duration
|
||||
if last is None or start < max(last.span.start, plan.passage_start or plan.start) or any(a.role != 'terminal' and a.latest + a.duration > start for a in anchors):
|
||||
continue
|
||||
changed = dataclasses.replace(terminal, earliest=start, latest=start, duration=duration)
|
||||
result.append(tuple(changed if a.id == terminal.id else a for a in anchors))
|
||||
return tuple(result) or tuple([anchors])
|
||||
|
||||
def align_anchors(slots, anchors, plan):
|
||||
"""Choose arrival timing with the rhythm, before assigning any free pitches."""
|
||||
slots = list(slots)
|
||||
exact = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
|
||||
protected_end = plan.start + plan.motif.duration if exact else plan.start
|
||||
for anchor in anchors:
|
||||
if not plan.start <= anchor.earliest < plan.end - plan.release:
|
||||
continue
|
||||
possible = [s for s in slots if anchor.earliest <= s.start <= anchor.latest]
|
||||
fit = [s for s in possible if s.end - s.start >= anchor.duration]
|
||||
selected = (fit or possible)
|
||||
slot = selected[0] if selected else None
|
||||
time = slot.start if slot else anchor.earliest
|
||||
end = anchor.hold_until or time + anchor.duration
|
||||
end = min(end, plan.end - plan.release)
|
||||
if time < protected_end:
|
||||
if slot is None or slot.end < end or (slot.forced and slot.forced != anchor.pitch):
|
||||
return None
|
||||
slots[slots.index(slot)] = dataclasses.replace(slot, forced=anchor.pitch)
|
||||
continue
|
||||
group = slot.group if slot else next((s.group for s in reversed(slots) if s.start <= time), 0)
|
||||
event_id = slot.id if slot else anchor.id
|
||||
replacement = Slot(event_id, time, end, group, slot.reference if slot else 0, anchor.pitch)
|
||||
before = [dataclasses.replace(s, end=min(s.end, time)) for s in slots if s.start < time]
|
||||
slots = [s for s in before if s.end > s.start] + [replacement] + [s for s in slots if s.start >= end and s.start != time]
|
||||
return tuple(slots)
|
||||
|
||||
def construct(plan, reference, relationship, template, anchors, variant, choices, octave=0):
|
||||
identifier, pattern, period = template
|
||||
if not pattern or period <= 0:
|
||||
return None
|
||||
finish = plan.end - plan.release
|
||||
slots, groups, cursor = (), [], plan.start
|
||||
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
|
||||
schedule = (('repeat', 'extend', 'answer'), ('extend', 'displace', 'recall'))[variant % 2]
|
||||
while cursor < finish:
|
||||
index = len(groups)
|
||||
operation = relationship if index == 0 else schedule[(index - 1) % len(schedule)]
|
||||
activity = next((p for p in plan.passages if p.start <= cursor < p.end), None)
|
||||
rate = F(len(pattern), 1) / period
|
||||
unit = F(2) if activity and activity.activity < F(3, 4) else F(1, 2) if activity and activity.activity > rate + F(1, 4) and index else F(1)
|
||||
local = pattern
|
||||
length = period
|
||||
if index and operation == 'extend':
|
||||
slots, cursor = reopen(slots, cursor)
|
||||
# Continue a recognizable opening beyond the old cadence; this group
|
||||
# can span either side of a barline without a new catalog selection.
|
||||
tail = pattern[:2]
|
||||
local = (*pattern, *((period + a, period + b) for a, b in tail))
|
||||
length += tail[-1][1]
|
||||
elif index and operation == 'answer':
|
||||
local = pattern[:max(2, len(pattern) - 1)]
|
||||
length = local[-1][1]
|
||||
elif index and operation == 'recall':
|
||||
operation = 'repeat_with_harmonic_adaptation'
|
||||
elif index and operation == 'displace':
|
||||
local = tuple((a + F(1, 2), b + F(1, 2)) for a, b in pattern)
|
||||
length += F(1, 2)
|
||||
if index and unit == 2:
|
||||
operation = 'fragment'
|
||||
local = local[:3]
|
||||
length = local[-1][1]
|
||||
if index and not protected:
|
||||
connected = operation not in ('repeat', 'repeat_with_harmonic_adaptation')
|
||||
local, length = develop_timing(local, length, unit, activity.occupancy if activity else F(3, 4), connected)
|
||||
if unit < 1:
|
||||
operation = 'diminish' if operation != 'extend' else 'extend_diminished'
|
||||
elif unit > 1:
|
||||
operation = 'broaden_ending'
|
||||
unit = F(1)
|
||||
if activity and activity.intent == 'space':
|
||||
cursor = min(activity.end, finish)
|
||||
continue
|
||||
if cursor + unit * length > finish:
|
||||
operation = 'close' if index else operation
|
||||
group_end = min(finish, cursor + unit * length)
|
||||
if group_end <= cursor:
|
||||
break
|
||||
group = Group(f'{plan.id}_development_{index + 1}', operation, cursor, group_end, identifier)
|
||||
groups.append(group)
|
||||
new = []
|
||||
for j, (a, b) in enumerate(local):
|
||||
start, end = cursor + a * unit, min(group_end, cursor + b * unit)
|
||||
if start >= end:
|
||||
continue
|
||||
quote = protected and index == 0
|
||||
pitch = None
|
||||
if quote and plan.treatment in ('quote', 'exact_quote'):
|
||||
pitch = dataclasses.replace(reference.notes[j].pitch, octave=reference.notes[j].pitch.octave + octave)
|
||||
new.append(Slot(f'{group.id}_event_{j + 1}', start, end, index, j % len(pattern), pitch, quote))
|
||||
slots = (*slots, *new)
|
||||
cursor = group_end
|
||||
if protected and plan.start + reference.duration > finish:
|
||||
return None
|
||||
aligned = align_anchors(slots, anchors, plan)
|
||||
if aligned is None:
|
||||
return None
|
||||
# Missing harmony is explicit space. Only ordinary material may fragment.
|
||||
filtered = []
|
||||
for slot in aligned:
|
||||
pieces = [(slot.start, slot.end)]
|
||||
for choice in choices:
|
||||
if choice.scale is None:
|
||||
pieces = [(a, b) for low, high in pieces for a, b in ((low, min(high, choice.span.start)), (max(low, choice.span.end), high)) if b > a]
|
||||
if slot.protected and pieces != [(slot.start, slot.end)]:
|
||||
return None
|
||||
filtered.extend(dataclasses.replace(slot, id=slot.id + (f'_fragment_{i}' if len(pieces) > 1 else ''), start=a, end=b) for i, (a, b) in enumerate(pieces))
|
||||
# The finalized rhythm is the contract, including changes made by extension
|
||||
# and anchor alignment. It is never rebased when a cell or bar ends.
|
||||
identity = generator.solo.policy.stable_rank(plan.seed, (identifier, variant, anchors, relationship, octave))[:12]
|
||||
return RhythmDraft(identity, tuple(filtered), tuple(groups), anchors, reference, relationship, octave)
|
||||
|
||||
def rhythms(plan, incoming, measures, choices, library, batch=0):
|
||||
continuing = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
|
||||
anchors = (incoming.selected_anchors,) if continuing and incoming.selected_anchors else plan.anchor_options or tuple([()])
|
||||
references = [(plan.motif, 'statement')]
|
||||
if incoming.home and plan.treatment not in ('quote', 'exact_quote', 'rhythmic_reference'):
|
||||
references = [(incoming.home, 'continue' if incoming.ending == 'connected' or continuing else 'answer')]
|
||||
if not continuing:
|
||||
references.append((incoming.home, 'recall'))
|
||||
if plan.motif.id != incoming.home.parent:
|
||||
references.append((plan.motif, 'contrast'))
|
||||
if incoming.themes and incoming.themes[0].id != incoming.home.id:
|
||||
references.append((incoming.themes[0], 'recall'))
|
||||
elapsed = F()
|
||||
meter = measures[0]['time_signature']
|
||||
for measure in measures:
|
||||
end = elapsed + generator.model.fraction(measure['duration'])
|
||||
if elapsed <= plan.start < end:
|
||||
meter = measure['time_signature']
|
||||
break
|
||||
elapsed = end
|
||||
result = []
|
||||
for reference, relationship in references:
|
||||
available_templates = templates(plan, reference, library, meter)
|
||||
if isinstance(reference, generator.solo.types.RealizedMotif):
|
||||
available_templates = available_templates[:1]
|
||||
for template in available_templates:
|
||||
for backbone in anchors:
|
||||
endings = (backbone,) if continuing else ending_options(backbone, plan, choices)
|
||||
for ending in endings:
|
||||
for variant in range(2):
|
||||
for octave in ((0, 1, -1, 2, -2) if plan.treatment in ('quote', 'exact_quote') else tuple([0])):
|
||||
rhythm = construct(plan, reference, relationship, template, ending, variant, choices, octave)
|
||||
if rhythm:
|
||||
result.append(rhythm)
|
||||
evaluation = generator.solo.scoring.Evaluation(dataclasses.replace(plan, passages=tuple(p for p in plan.passages if p.end > plan.start)), choices)
|
||||
|
||||
def rank(rhythm):
|
||||
dummy = tuple(generator.solo.types.Note(s.start, s.end, s.forced or rhythm.reference.notes[0].pitch) for s in rhythm.slots)
|
||||
features = evaluation.features(dummy)
|
||||
return features['activity'] * 4 + features['occupancy'] * 4, generator.solo.policy.stable_rank(plan.seed, rhythm.id)
|
||||
|
||||
def family(rhythm):
|
||||
pitches = tuple(a.pitch.number() for a in rhythm.anchors)
|
||||
shape = generator.solo.sequences.family(tuple(b - a for a, b in zip(pitches, pitches[1:])))
|
||||
return rhythm.relationship, shape, rhythm.groups[0].source, rhythm.anchors[-1].duration if rhythm.anchors else None
|
||||
|
||||
limit = generator.solo.policy.DEFAULT.draft_frontier
|
||||
return generator.solo.sequences.diverse(result, rank, family, limit * (batch + 1))[limit * batch:]
|
||||
|
||||
def canonical_context(notes, incoming, plan):
|
||||
prefix = incoming.notes if incoming.passage_id == plan.passage_id and plan.passage_id else ((incoming.last,) if incoming.last else ())
|
||||
return generator.solo.types.sounding_notes((*prefix, *notes))
|
||||
|
||||
def local_sounding(notes):
|
||||
if notes and notes[0].continuation:
|
||||
notes = (dataclasses.replace(notes[0], continuation=False), *notes[1:])
|
||||
return generator.solo.types.sounding_notes(notes)
|
||||
|
||||
def contract_valid(draft, notes):
|
||||
by_id = {n.event_id: n for n in local_sounding(notes)}
|
||||
original = {n.event_id: n for n in draft.notes}
|
||||
for identity in (*draft.structural, *draft.protected):
|
||||
before, after = original[identity], by_id.get(identity)
|
||||
if after is None or (before.start, before.end, before.pitch) != (after.start, after.end, after.pitch):
|
||||
return False, f'{identity}: protected draft event changed'
|
||||
for transform in draft.transformations:
|
||||
if not transform.preserves:
|
||||
continue
|
||||
core = [by_id.get(identity) for identity in transform.events[:min(3, len(transform.events))]]
|
||||
if not all(core):
|
||||
return False, f'{transform.id}: identifying events missing'
|
||||
if 'rhythm' in transform.preserves and tuple((n.start - transform.start, n.end - transform.start) for n in core) != transform.rhythm[:len(core)]:
|
||||
return False, f'{transform.id}: identifying rhythm changed'
|
||||
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(core, core[1:]))
|
||||
if 'opening_contour' in transform.preserves and directions != transform.directions[:len(directions)]:
|
||||
return False, f'{transform.id}: identifying contour changed'
|
||||
return True, ''
|
||||
|
||||
def relationship_features(draft, notes):
|
||||
"""Inspect relationships once, on canonical events, independent of cells."""
|
||||
notes = local_sounding(notes)
|
||||
by_id = {n.event_id: n for n in notes}
|
||||
preservation = sum(identity not in by_id for identity in draft.structural) / max(1, len(draft.structural))
|
||||
direction, development = [], []
|
||||
for transform in draft.transformations:
|
||||
group = [by_id[i] for i in transform.events if i in by_id]
|
||||
actual = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(group, group[1:]))
|
||||
direction.extend(a != b for a, b in zip(actual, transform.directions))
|
||||
if draft.home and transform.operation not in ('statement', 'contrast', 'close', 'fragment', 'broaden_ending'):
|
||||
development.append(generator.solo.scoring.motif_fidelity(group[:3], draft.home, 'opening_reference'))
|
||||
joins = []
|
||||
for time in draft.joins:
|
||||
left = next((n for n in reversed(notes) if n.start < time), None)
|
||||
right = next((n for n in notes if n.start >= time), None)
|
||||
if left and right:
|
||||
original_left = next((n for n in reversed(draft.notes) if n.start < time), None)
|
||||
original_right = next((n for n in draft.notes if n.start >= time), None)
|
||||
planned_gap = max(F(), original_right.start - original_left.end) if original_left and original_right else F()
|
||||
gap = max(F(), right.start - left.end - planned_gap)
|
||||
joins.append(min(1, float(gap) / 2) + min(1, max(0, abs(right.pitch.number() - left.pitch.number()) - 4) / 12))
|
||||
terminal = next((a for a in draft.anchors if a.role == 'terminal' and a.earliest < draft.end), None)
|
||||
integration = 0.0
|
||||
if terminal:
|
||||
arrival = generator.solo.harmonic.anchor_note(terminal, notes)
|
||||
before = next((n for n in reversed(notes) if arrival and n.start < arrival.start), None)
|
||||
integration = min(1, max(0, abs(arrival.pitch.number() - before.pitch.number()) - 4) / 12) if arrival and before else 0
|
||||
return {'draft_preservation': preservation, 'development': sum(development) / max(1, len(development)), 'direction': sum(direction) / max(1, len(direction)), 'join_coherence': sum(joins) / max(1, len(joins)), 'ending_integration': integration}
|
||||
@@ -130,7 +130,7 @@ def anchor_backbones(identifier, start, end, release, register, choices, bar, se
|
||||
if terminal_start <= opening + fractions.Fraction(1, 2):
|
||||
moments = []
|
||||
moments.append((terminal_start, 'terminal'))
|
||||
states = [(0.0, ())]
|
||||
states = [(0.0, (), intention) for intention in ('rising', 'falling', 'returning', 'repeated')]
|
||||
for index, (time, role) in enumerate(moments):
|
||||
choice = next(c for c in active if c.span.start <= time < c.span.end)
|
||||
chord_roles = generator.solo.harmonic.roles(choice.span.chord)
|
||||
@@ -141,17 +141,18 @@ def anchor_backbones(identifier, start, end, release, register, choices, bar, se
|
||||
pitches = [p for p in pitches if register[0] <= p.number() <= register[1] and p.number() % 12 in eligible and generator.fretboard.single_positions_for_pitch(fractions.Fraction(p.number()))]
|
||||
desired = register[0] + (5 if index == 0 else 8 if role == 'terminal' else 11)
|
||||
following = []
|
||||
for cost, anchors in states:
|
||||
for cost, anchors, intention in states:
|
||||
for pitch in pitches:
|
||||
jump = abs(pitch.number() - anchors[-1].pitch.number()) if anchors else 0
|
||||
direction = 1 if role != 'terminal' else -1
|
||||
direction = 1 if intention == 'rising' or intention == 'returning' and role != 'terminal' else -1 if intention != 'repeated' else 0
|
||||
wrong_way = bool(anchors and (pitch.number() - anchors[-1].pitch.number()) * direction < 0)
|
||||
preferred = target_pitches(choice.span.chord, role == 'terminal')
|
||||
score = cost + jump / 12 + 0.3 * wrong_way + abs(pitch.number() - desired) / 18 + 0.3 * (pitch.number() % 12 not in chord_roles.basic) + 0.2 * (role == 'terminal' and pitch.number() % 12 not in preferred)
|
||||
destination_motion = 0 if intention == 'repeated' else 4
|
||||
score = cost + abs(jump - destination_motion) / 12 + 0.8 * wrong_way + abs(pitch.number() - desired) / 36 + 0.3 * (pitch.number() % 12 not in chord_roles.basic) + 0.2 * (role == 'terminal' and pitch.number() % 12 not in preferred)
|
||||
anchor = generator.solo.types.Anchor(f'{identifier}_anchor_{index + 1}', time, latest, pitch, frozenset(eligible), register, duration, role, generator.harmony.symbol(choice.span.chord), False, finish if role == 'terminal' else None)
|
||||
following.append((score, (*anchors, anchor)))
|
||||
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], generator.solo.policy.stable_rank(seed, tuple(a.pitch for a in item[1]))), lambda item: (item[1][0].pitch.number(), item[1][-1].pitch.number()), generator.solo.policy.DEFAULT.alternatives))
|
||||
return tuple(anchors for _, anchors in states)
|
||||
following.append((score, (*anchors, anchor), intention))
|
||||
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], generator.solo.policy.stable_rank(seed, tuple(a.pitch for a in item[1]))), lambda item: (item[2], item[1][0].pitch.number()), generator.solo.policy.DEFAULT.anchor_lines))
|
||||
return tuple(dict.fromkeys(anchors for _, anchors, _ in states))
|
||||
|
||||
def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choices, end: fractions.Fraction, part: str, sequences: generator.solo.sequences.Library) -> tuple[generator.solo.types.PhrasePlan, ...]:
|
||||
boundaries = [fractions.Fraction()]
|
||||
@@ -207,23 +208,13 @@ def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choi
|
||||
if slot < 2 and slot not in inventions:
|
||||
inventions[slot] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed, variant=slot)
|
||||
motif = head if slot == 2 else inventions[slot]
|
||||
gestures, cursor = [], start
|
||||
while cursor < finish - release:
|
||||
gesture_end = min(cursor + bar, finish - release)
|
||||
closing = gesture_end == finish - release
|
||||
changed = any(c.span.start == cursor and c.scale and prior.scale and c.scale.pitches != prior.scale.pitches for prior, c in zip(choices, choices[1:]))
|
||||
relationship = 'statement' if not gestures else 'close' if closing else 'repeat_with_harmonic_adaptation' if changed else 'answer' if cursor >= start + (finish - start) / 2 else 'extend'
|
||||
treatment = 'opening_reference' if not gestures and motif.origin == 'melody' else 'invented' if not gestures else 'rhythmic_reference' if relationship == 'repeat_with_harmonic_adaptation' else 'answer' if relationship in ('answer', 'close') else 'contour_reference'
|
||||
gestures.append(generator.solo.types.Gesture(f'{passage_id}_gesture_{len(gestures) + 1}', cursor, gesture_end, relationship, treatment))
|
||||
cursor = gesture_end
|
||||
phrase_start = start
|
||||
for phrase_stop in phrase_stops:
|
||||
phrase_end = boundaries[phrase_stop]
|
||||
terminal = phrase_end == finish
|
||||
treatment = 'opening_reference' if motif.origin == 'melody' else 'invented'
|
||||
local_gestures = tuple(g for g in gestures if g.start < phrase_end and g.end > phrase_start)
|
||||
landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, min(v[i].pitch.number() for v in anchors), max(v[i].pitch.number() for v in anchors), 0, a.role) for i, a in enumerate(anchors[0])) if anchors and anchors[0] else ()
|
||||
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', phrase_start, phrase_end, motif, 'statement' if phrase_start == start else 'continue', treatment, None, register, activity, release if terminal else fractions.Fraction(), envelope, landmarks, 'breath' if terminal and release else 'sustain' if terminal else 'connected', 'closed' if terminal else 'open', passage_seed, passage_id, start, finish, anchors, local_gestures))
|
||||
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', phrase_start, phrase_end, motif, 'statement' if phrase_start == start else 'continue', treatment, None, register, activity, release if terminal else fractions.Fraction(), envelope, landmarks, 'breath' if terminal and release else 'sustain' if terminal else 'connected', 'closed' if terminal else 'open', passage_seed, passage_id, start, finish, anchors))
|
||||
phrase_start = phrase_end
|
||||
index = stop
|
||||
return tuple(result)
|
||||
|
||||
@@ -11,7 +11,7 @@ class Policy:
|
||||
electric_activity: float = 1.375
|
||||
active_occupancy: float = 0.75
|
||||
arrival_occupancy: float = 0.9
|
||||
weights: tuple[tuple[str, float], ...] = (('activity', 4), ('occupancy', 4), ('fidelity', 3), ('landmarks', 3), ('register', 1), ('contour', 1), ('connection', 1), ('repetition', 1), ('movement', 0.5), ('harmonic_exposure', 2))
|
||||
weights: tuple[tuple[str, float], ...] = (('activity', 4), ('occupancy', 4), ('fidelity', 3), ('landmarks', 3), ('register', 1), ('contour', 1), ('connection', 1), ('repetition', 1), ('movement', 0.5), ('harmonic_exposure', 2), ('draft_preservation', 3), ('development', 2), ('direction', 1), ('join_coherence', 2), ('ending_integration', 2))
|
||||
beam_width: int = 64
|
||||
extensions: int = 32
|
||||
retrieval: int = 32
|
||||
@@ -21,6 +21,12 @@ class Policy:
|
||||
suspension_preparation: float = 0.5
|
||||
suspension_resolution: float = 1.0
|
||||
replan_attempts: int = 2
|
||||
anchor_lines: int = 8
|
||||
draft_frontier: int = 16
|
||||
phrase_alternatives: int = 8
|
||||
join_alternatives: int = 8
|
||||
revision_sweeps: int = 2
|
||||
join_window: float = 2.0
|
||||
|
||||
# Preserve the former default root seeds while decoupling them from policy edits.
|
||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||
|
||||
+410
-100
@@ -3,6 +3,9 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
import time
|
||||
|
||||
import generator.solo.drafts
|
||||
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
@@ -348,121 +351,428 @@ def candidates(cell, plan, choices, sequences, state=None, anchors=(), harmony=N
|
||||
class SearchContext:
|
||||
pools: dict = dataclasses.field(default_factory=dict)
|
||||
results: dict = dataclasses.field(default_factory=dict)
|
||||
resources: dict = dataclasses.field(default_factory=dict)
|
||||
revise: bool = True
|
||||
batch: int = 0
|
||||
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'
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class BeamState:
|
||||
score: float
|
||||
decisions: tuple
|
||||
music: generator.solo.types.ContinuationState
|
||||
travel: int = 0
|
||||
fidelity: tuple[float, ...] = ()
|
||||
def phrase_context(plan, incoming, notes):
|
||||
return generator.solo.drafts.canonical_context(notes, incoming, plan)
|
||||
|
||||
def evaluation_plan(plan, anchors):
|
||||
landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, a.pitch.number(), a.pitch.number(), name=a.role) for a in anchors)
|
||||
return dataclasses.replace(plan, start=plan.passage_start if plan.passage_start is not None else plan.start, end=plan.passage_end or plan.end, landmarks=landmarks)
|
||||
|
||||
def inherited_treatments(plan, incoming):
|
||||
if plan.passage_id and plan.passage_id == incoming.passage_id:
|
||||
return incoming.treatments
|
||||
retained = []
|
||||
for treatment in incoming.treatments:
|
||||
future = treatment.start >= plan.start
|
||||
color = treatment.kind == 'color' and incoming.last and treatment.end > incoming.last.start
|
||||
pending = treatment.kind == 'suspension' and incoming.last and incoming.last.pitch.number() == treatment.pitch and incoming.last.end > treatment.start and not any(n.start >= treatment.start and n.pitch.number() == treatment.resolution for n in incoming.notes)
|
||||
if future or color or pending:
|
||||
retained.append(treatment)
|
||||
# Completed contracts remain in their original phrase reports; only the
|
||||
# sounding tail and unresolved obligations require context in a new passage.
|
||||
return tuple(retained)
|
||||
|
||||
def draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, context):
|
||||
"""Instantiate a complete timed line. Boundaries here are musical groups."""
|
||||
F = fractions.Fraction
|
||||
contracts = tuple(dict.fromkeys((*inherited_treatments(plan, incoming), *plan.treatments, *anchor_treatments(rhythm.anchors, harmony))))
|
||||
# Cost, actual pitches so far, and the identity of the introduced statement.
|
||||
states = [(0.0, (), rhythm.reference.notes if isinstance(rhythm.reference, generator.solo.types.RealizedMotif) else ())]
|
||||
next_anchor = [next((a for a in rhythm.anchors if a.latest >= s.start), None) for s in rhythm.slots]
|
||||
for index, slot in enumerate(rhythm.slots):
|
||||
available = options(choices, slot.start, slot.end, plan.register)
|
||||
pending = next((t for t in contracts if t.kind == 'suspension' and t.deadline is not None and incoming.last and incoming.last.pitch.number() == t.pitch and incoming.last.end == slot.start and t.start <= slot.start <= t.deadline and not any(n.start >= t.start and n.pitch.number() == t.resolution for n in incoming.notes)), None)
|
||||
if pending:
|
||||
available = tuple(p for p in available if p.number() == pending.resolution)
|
||||
if slot.forced:
|
||||
available = tuple(p for p in available if p == slot.forced)
|
||||
quoted = slot.protected and plan.treatment in ('quote', 'exact_quote')
|
||||
if not quoted and slot.end - slot.start >= generator.solo.policy.DEFAULT.long_note:
|
||||
members = harmony.members(slot.start, slot.end)
|
||||
available = tuple(p for p in available if p.number() % 12 in members or any(t.pitch == p.number() and t.start <= slot.start and t.end >= slot.end for t in contracts))
|
||||
if not available:
|
||||
return (), f'{slot.id}: no valid pitch for sounding interval {slot.start}-{slot.end}'
|
||||
following = []
|
||||
group = rhythm.groups[slot.group]
|
||||
local_index = sum(s.group == slot.group for s in rhythm.slots[:index])
|
||||
remaining = sum(s.start >= slot.start and (not next_anchor[index] or s.start <= next_anchor[index].latest) for s in rhythm.slots[index:])
|
||||
span = harmony.at(slot.start)
|
||||
for cost, notes, home in states:
|
||||
prior = notes[-1] if notes else incoming.last
|
||||
reference = home if home and slot.group else rhythm.reference.notes
|
||||
if local_index >= 3 and group.operation in ('extend', 'extend_diminished', 'answer', 'continue'):
|
||||
previous_ids = {s.id for s in rhythm.slots if s.group == slot.group - 1}
|
||||
recent = tuple(n for n in notes if n.event_id in previous_ids) if slot.group else incoming.development.notes if incoming.development else ()
|
||||
reference = recent or reference
|
||||
contour = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(reference, reference[1:]))
|
||||
step = contour[(max(0, slot.reference - 1)) % len(contour)] if contour else 0
|
||||
if slot.reference == 0 and local_index == 0:
|
||||
# A group continues the incoming line; it does not teleport to the
|
||||
# source motif's register or to the center of a fresh bar.
|
||||
step = contour[-1] if contour and group.operation == 'extend' else 0
|
||||
expected = prior.pitch.number() + step if prior else rhythm.reference.notes[0].pitch.number()
|
||||
anchor = next_anchor[index]
|
||||
target = anchor.pitch.number() if anchor else expected
|
||||
route = expected
|
||||
if prior and anchor and span and span.scale:
|
||||
try:
|
||||
start_degree = generator.solo.types.scale_index(span.scale, prior.pitch.number())
|
||||
target_degree = generator.solo.types.scale_index(span.scale, target)
|
||||
displacement = target_degree - start_degree
|
||||
count = min(16, max(2, remaining + 1))
|
||||
paths = sequences.retrieve(count, displacement, (displacement > 0) - (displacement < 0))
|
||||
if paths:
|
||||
route = generator.solo.types.scale_pitch(span.scale, start_degree + paths[0].steps[0]).number()
|
||||
except ValueError:
|
||||
# Collection changes preserve the incoming sound, never its old index.
|
||||
route = prior.pitch.number() + ((target > prior.pitch.number()) - (target < prior.pitch.number())) * 2
|
||||
ranked = sorted(available, key=lambda p: (abs(p.number() - expected), p))[:3]
|
||||
if available:
|
||||
bridge = min(available, key=lambda p: (abs(p.number() - route), p))
|
||||
if bridge not in ranked:
|
||||
ranked.append(bridge)
|
||||
for pitch in ranked:
|
||||
direction = (pitch.number() > prior.pitch.number()) - (pitch.number() < prior.pitch.number()) if prior else 0
|
||||
intended = (step > 0) - (step < 0)
|
||||
identifying = bool(local_index in (1, 2) and group.operation not in ('close', 'answer') and not slot.forced)
|
||||
if identifying and reference and direction != intended:
|
||||
continue
|
||||
jump = abs(pitch.number() - prior.pitch.number()) if prior else 0
|
||||
stopping = slot.end - slot.start >= generator.solo.policy.DEFAULT.stopping_note
|
||||
motion_cost = abs(pitch.number() - expected) / 12 * (1.5 if identifying else 0.35)
|
||||
direction_cost = max(0, abs(pitch.number() - target) - 2 * max(0, remaining - 1)) / 12 if anchor else 0
|
||||
exposure = 0.6 if stopping and span and pitch.number() % 12 not in span.roles.basic and not quoted else 0
|
||||
score = cost + motion_cost + direction_cost + max(0, jump - 5) / 12 + exposure
|
||||
sources = reference[min(slot.reference, len(reference) - 1)].sources if reference else ()
|
||||
note = generator.solo.types.Note(slot.start, slot.end, pitch, sources, 'quote' if quoted else 'motif', event_id=slot.id)
|
||||
path = (*notes, note)
|
||||
identity = home
|
||||
if not identity and (index + 1 == len(rhythm.slots) or rhythm.slots[index + 1].group != slot.group):
|
||||
identity = tuple(n for n in path if n.event_id in {s.id for s in rhythm.slots if s.group == slot.group})
|
||||
following.append((score, path, identity))
|
||||
if not following:
|
||||
return (), f'{group.id}: identifying motion cannot reach the selected anchor within the draft budget'
|
||||
boundary = index + 1 == len(rhythm.slots) or rhythm.slots[index + 1].group != slot.group
|
||||
limit = generator.solo.policy.DEFAULT.draft_frontier if boundary else BEAM_WIDTH
|
||||
# Relevant home/contour alternatives survive; no barline participates.
|
||||
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], tuple(n.pitch for n in item[1])), lambda item: (tuple(n.pitch for n in item[2][:3]), item[1][-1].pitch), limit))
|
||||
context.diagnostics['evaluated_extensions'] += len(following)
|
||||
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
|
||||
return tuple((notes, cost / max(1, len(notes)), contracts) for cost, notes, _ in states[:4]), ''
|
||||
|
||||
def make_identity(identifier, parent, notes, start, end, relationship, harmony):
|
||||
local = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start, position=None) for n in notes)
|
||||
degrees = tuple(dict(harmony.at(n.start).roles.degrees).get(n.pitch.number() % 12, ()) for n in notes)
|
||||
return generator.solo.types.RealizedMotif(identifier, parent, local, end - start, relationship, 'developed_rhythm', degrees, start)
|
||||
|
||||
def make_draft(rhythm, notes, plan, harmony, incoming):
|
||||
transforms, structural, protected = [], [], []
|
||||
home = incoming.home if rhythm.relationship not in ('statement', 'contrast') else None
|
||||
if rhythm.relationship == 'recall' and isinstance(rhythm.reference, generator.solo.types.RealizedMotif):
|
||||
home = rhythm.reference
|
||||
parent = incoming.development.id if incoming.development and home else rhythm.reference.id
|
||||
for index, group in enumerate(rhythm.groups):
|
||||
ids = {s.id for s in rhythm.slots if s.group == index}
|
||||
actual = tuple(n for n in notes if n.event_id in ids)
|
||||
if not actual:
|
||||
continue
|
||||
next_start = rhythm.groups[index + 1].start if index + 1 < len(rhythm.groups) else group.end
|
||||
start, end = group.start, max(min(group.end, next_start), actual[-1].end)
|
||||
identity = make_identity(group.id, parent, actual, start, end, group.operation, harmony)
|
||||
if home is None:
|
||||
home = identity
|
||||
structural.extend(n.event_id for n in actual[:3])
|
||||
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(actual, actual[1:]))
|
||||
# Closing simplification explicitly releases the characteristic core.
|
||||
preserves = () if group.operation == 'close' else ('rhythm', 'opening_contour')
|
||||
if plan.treatment == 'rhythmic_reference':
|
||||
preserves = ('rhythm',)
|
||||
transforms.append(generator.solo.types.Transformation(group.id, parent, home.id, group.operation, start, end, tuple(n.event_id for n in actual), tuple((n.start - start, n.end - start) for n in actual), directions, preserves, group.source))
|
||||
parent = identity.id
|
||||
protected.extend(s.id for s in rhythm.slots if s.protected)
|
||||
for anchor in rhythm.anchors:
|
||||
note = generator.solo.harmonic.anchor_note(anchor, notes)
|
||||
if note:
|
||||
structural.append(note.event_id)
|
||||
joins = tuple(t.start for t in transforms[1:])
|
||||
return generator.solo.types.MelodicDraft(rhythm.id, plan.start, plan.end, notes, tuple(transforms), tuple(dict.fromkeys(structural)), tuple(protected), rhythm.anchors, home, rhythm.relationship, joins)
|
||||
|
||||
def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edits=()):
|
||||
valid, _ = generator.solo.drafts.contract_valid(draft, notes)
|
||||
if not valid:
|
||||
return None
|
||||
placed = generator.solo.placement.place(notes, tempo, incoming.last)
|
||||
if placed is None:
|
||||
return None
|
||||
travel, notes = placed
|
||||
try:
|
||||
canonical = phrase_context(plan, incoming, notes)
|
||||
except ValueError:
|
||||
return None
|
||||
complete = plan.end == (plan.passage_end or plan.end)
|
||||
checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete)
|
||||
valid, _ = generator.solo.harmonic.anchors_valid(draft.anchors, canonical, plan.end, complete=complete, closure=plan.closure)
|
||||
if not checked.valid or not valid or not target_ok(notes, plan.target):
|
||||
return None
|
||||
shared = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
|
||||
score_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
|
||||
evaluation = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end)
|
||||
if not evaluation.feasible(score_notes):
|
||||
return None
|
||||
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0))
|
||||
features.update(generator.solo.drafts.relationship_features(draft, notes))
|
||||
# Fidelity is checked against explicit transformed identities, not against
|
||||
# the source head as one undifferentiated phrase-long quotation.
|
||||
features['fidelity'] = features['development']
|
||||
features['harmonic_exposure'] = checked.exposure
|
||||
intended_repeats = 0
|
||||
if draft.home:
|
||||
reference = draft.home.notes
|
||||
for transform in draft.transformations:
|
||||
actual = tuple(n for n in notes if n.event_id in transform.events)
|
||||
for i, (a, b, c) in enumerate(zip(actual, actual[1:], actual[2:])):
|
||||
expected = tuple(reference[j % len(reference)].pitch for j in (i, i + 1, i + 2)) if reference else ()
|
||||
if a.pitch == b.pitch == c.pitch and len(expected) == 3 and expected[0] == expected[1] == expected[2]:
|
||||
intended_repeats += 1
|
||||
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(score_notes) - 2))
|
||||
score = generator.solo.scoring.value(features)
|
||||
motifs = list(incoming.motifs) if shared else []
|
||||
decisions = []
|
||||
for transform in draft.transformations:
|
||||
actual = tuple(n for n in notes if n.event_id in transform.events or transform.start <= n.start < transform.end and n.event_id not in {e for t in draft.transformations for e in t.events})
|
||||
if not actual:
|
||||
continue
|
||||
identity = make_identity(transform.id, transform.parent, actual, transform.start, transform.end, transform.operation, harmony)
|
||||
motifs.append(identity)
|
||||
treatment = f'quote octave {round((actual[0].pitch.number() - plan.motif.notes[0].pitch.number()) / 12):+}' if actual[0].role == 'quote' else transform.operation
|
||||
decisions.append(generator.solo.types.Decision(min(n.start for n in actual), max(n.end for n in actual), 'phrase draft ' + draft.id + '; ' + transform.operation, treatment, actual, 0, transform.id, transform.parent))
|
||||
# Output ordering follows absolute events, even when a transformation's tail
|
||||
# was reopened by an overlapping extension.
|
||||
decisions.sort(key=lambda d: d.start)
|
||||
emitted = tuple(n for d in decisions for n in d.notes)
|
||||
if emitted != notes:
|
||||
return None
|
||||
home = next((m for m in motifs if draft.home and m.id == draft.home.id), draft.home)
|
||||
themes = tuple(dict.fromkeys((*incoming.themes, *((home,) if home else ()))))
|
||||
state_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
|
||||
last = state_notes[-1] if state_notes else incoming.last
|
||||
state = generator.solo.types.ContinuationState(plan.passage_id, state_notes, tuple(motifs), contracts,
|
||||
tuple(a.id for a in draft.anchors if generator.solo.harmonic.anchor_note(a, canonical)),
|
||||
(notes[-1].pitch.number() > notes[0].pitch.number()) - (notes[-1].pitch.number() < notes[0].pitch.number()) if notes else incoming.direction,
|
||||
last, 0, (), travel + (incoming.travel if shared else 0), home, motifs[-1] if motifs else incoming.development, themes, draft.anchors, plan.ending)
|
||||
actual_plan = dataclasses.replace(plan, relationship=draft.relationship)
|
||||
return generator.solo.types.Phrase(actual_plan, tuple(decisions), score, state, draft.anchors, draft, edits)
|
||||
|
||||
def legacy_target_plan(plan, choices):
|
||||
if not plan.target or plan.anchor_options:
|
||||
return plan
|
||||
target = plan.target
|
||||
duration = fractions.Fraction(1, 2)
|
||||
arrival = next((p for p in plan.passages if p.start <= target.earliest < p.end and p.intent == 'sustained_arrival'), None)
|
||||
if arrival and arrival.activity <= fractions.Fraction(1, 4):
|
||||
duration = arrival.end - target.earliest
|
||||
pitches = options(choices, target.earliest, target.earliest + duration, plan.register)
|
||||
anchors = tuple((generator.solo.types.Anchor(plan.id + '_target', target.earliest, target.latest, pitch, target.pitches, plan.register, duration, 'terminal', target.chord, True, target.earliest + duration),) for pitch in pitches if pitch.number() % 12 in target.pitches)
|
||||
return dataclasses.replace(plan, anchor_options=anchors)
|
||||
|
||||
def emitted_notes(phrase):
|
||||
return tuple(n for d in phrase.decisions for n in d.notes)
|
||||
|
||||
def window_variants(phrase, low, high, harmony, sequences, kind):
|
||||
"""Two fixed outside endpoints bound edits to the existing melody."""
|
||||
notes = emitted_notes(phrase)
|
||||
protected = set((*phrase.draft.structural, *phrase.draft.protected))
|
||||
inside = [i for i, n in enumerate(notes) if low <= n.start and n.end <= high]
|
||||
editable = [i for i in inside if notes[i].event_id not in protected]
|
||||
if not editable:
|
||||
return ()
|
||||
candidates = []
|
||||
left, right = max(0, inside[0] - 1), min(len(notes) - 1, inside[-1] + 1)
|
||||
# Route both sides of a join as one path, with the original outside pitches.
|
||||
if 1 < right - left < 16:
|
||||
scale = harmony.at(notes[left].start).scale
|
||||
try:
|
||||
first = generator.solo.types.scale_index(scale, notes[left].pitch.number())
|
||||
displacement = generator.solo.types.scale_index(scale, notes[right].pitch.number()) - first
|
||||
paths = sequences.retrieve(right - left + 1, displacement, (displacement > 0) - (displacement < 0))
|
||||
except ValueError:
|
||||
paths = ()
|
||||
for path in paths[:generator.solo.policy.DEFAULT.join_alternatives]:
|
||||
updated = list(notes)
|
||||
for i in editable:
|
||||
offset = path.offsets()[i - left]
|
||||
intended = generator.solo.types.scale_pitch(scale, first + offset).number()
|
||||
available = options(harmony.choices, notes[i].start, notes[i].end, phrase.plan.register)
|
||||
if available:
|
||||
pitch = min(available, key=lambda p: (abs(p.number() - intended), p))
|
||||
updated[i] = dataclasses.replace(notes[i], pitch=pitch, position=None)
|
||||
candidates.append((tuple(updated), 'sequence ' + ' + '.join(path.sources)))
|
||||
for index in editable:
|
||||
note = notes[index]
|
||||
if index + 1 < len(notes) and notes[index + 1].start <= high:
|
||||
after = notes[index + 1]
|
||||
if after.event_id not in protected and note.end == after.start and note.pitch == after.pitch:
|
||||
candidates.append(((*notes[:index], dataclasses.replace(note, end=after.end), *notes[index + 2:]), 'shared common-tone arrival'))
|
||||
if kind == 'join' and after.event_id not in protected and note.end == after.start and note.end - note.start > fractions.Fraction(1, 2):
|
||||
# The tail and pickup move together, preserving total sounding time.
|
||||
time = after.start - fractions.Fraction(1, 2)
|
||||
candidates.append(((*notes[:index], dataclasses.replace(note, end=time), dataclasses.replace(after, start=time), *notes[index + 2:]), 'reopen tail into pickup'))
|
||||
if kind == 'elaboration' and index + 1 < len(notes) and fractions.Fraction(1) <= note.end - note.start < 2:
|
||||
# Add motion only as an alternative evaluated against the unchanged
|
||||
# activity envelope; keeping the original draft is always available.
|
||||
split = note.end - fractions.Fraction(1, 2)
|
||||
scale = harmony.at(split).scale
|
||||
target = notes[index + 1].pitch
|
||||
try:
|
||||
degree = generator.solo.types.scale_index(scale, note.pitch.number())
|
||||
finish = generator.solo.types.scale_index(scale, target.number())
|
||||
pitch = generator.solo.types.scale_pitch(scale, degree + ((finish > degree) - (finish < degree)))
|
||||
addition = generator.solo.types.Note(split, note.end, pitch, note.sources, 'elaboration', event_id=note.event_id + '_passing')
|
||||
candidates.append(((*notes[:index], dataclasses.replace(note, end=split), addition, *notes[index + 1:]), 'interpolate toward next structural event'))
|
||||
except ValueError:
|
||||
pass
|
||||
return tuple(candidates)
|
||||
|
||||
def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind, context):
|
||||
original = emitted_notes(phrase)
|
||||
viable = [phrase]
|
||||
proposals = window_variants(phrase, low, high, harmony, sequences, kind)
|
||||
# Keep distinct rhythmic and pitch revisions; no generic unlimited search.
|
||||
proposals = generator.solo.sequences.diverse(proposals, lambda p: (abs(len(p[0]) - len(original)), p[1], tuple(n.pitch for n in p[0])), lambda p: (p[1].split()[0], len(p[0])), generator.solo.policy.DEFAULT.join_alternatives)
|
||||
for notes, source in proposals:
|
||||
if notes == original:
|
||||
continue
|
||||
changed_before = tuple(n.event_id for n in original if n not in notes)
|
||||
changed_after = tuple(n.event_id for n in notes if n not in original)
|
||||
edit = generator.solo.types.DraftEdit(kind, low, high, changed_before, changed_after, source)
|
||||
revised = checked_phrase(phrase.plan, phrase.draft, notes, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
|
||||
context.diagnostics[kind + '_attempts'] = context.diagnostics.get(kind + '_attempts', 0) + 1
|
||||
if revised:
|
||||
viable.append(revised)
|
||||
chosen = min(viable, key=lambda p: (p.score, len(p.edits), tuple((n.pitch, n.start, n.end) for n in emitted_notes(p))))
|
||||
if chosen is not phrase:
|
||||
context.diagnostics[kind + '_accepted'] = context.diagnostics.get(kind + '_accepted', 0) + 1
|
||||
return chosen
|
||||
|
||||
def elaborate(phrase, incoming, harmony, sequences, tempo, context):
|
||||
started = time.monotonic()
|
||||
for transform in phrase.draft.transformations:
|
||||
phrase = improve_window(phrase, incoming, harmony, sequences, tempo, transform.start, transform.end, 'elaboration', context)
|
||||
context.diagnostics['elaboration_seconds'] = context.diagnostics.get('elaboration_seconds', 0) + time.monotonic() - started
|
||||
return phrase
|
||||
|
||||
def revise_joins(phrase, incoming, harmony, sequences, tempo, context):
|
||||
started = time.monotonic()
|
||||
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
|
||||
for _ in range(generator.solo.policy.DEFAULT.revision_sweeps):
|
||||
before = phrase
|
||||
for join in phrase.draft.joins:
|
||||
phrase = improve_window(phrase, incoming, harmony, sequences, tempo, max(phrase.plan.start, join - width), min(phrase.plan.end, join + width), 'join', context)
|
||||
if phrase == before:
|
||||
break
|
||||
context.diagnostics['revision_seconds'] = context.diagnostics.get('revision_seconds', 0) + time.monotonic() - started
|
||||
return phrase
|
||||
|
||||
def revise_pair(first, second, incoming, choices, sequences, tempo, context):
|
||||
"""Revise an uncommitted connected phrase seam, then rebuild both states."""
|
||||
if not context.revise or not first.draft or not second.draft or first.plan.ending != 'connected' or first.plan.passage_id != second.plan.passage_id:
|
||||
return first, second
|
||||
incoming = incoming if isinstance(incoming, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=incoming)
|
||||
harmony = generator.solo.harmonic.Harmony(choices)
|
||||
seam = first.plan.end
|
||||
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
|
||||
combined = (*emitted_notes(first), *emitted_notes(second))
|
||||
draft = dataclasses.replace(first.draft, end=second.plan.end, notes=(*first.draft.notes, *second.draft.notes), transformations=(*first.draft.transformations, *second.draft.transformations), structural=tuple(dict.fromkeys((*first.draft.structural, *second.draft.structural))), protected=(*first.draft.protected, *second.draft.protected), joins=(*first.draft.joins, seam, *second.draft.joins))
|
||||
joint = dataclasses.replace(first, decisions=(generator.solo.types.Decision(first.plan.start, second.plan.end, 'provisional phrase pair', 'continue', combined),), draft=draft)
|
||||
candidates = [(second.score + generator.solo.drafts.relationship_features(draft, combined)['join_coherence'], first, second)]
|
||||
left_ids = {n.event_id for n in emitted_notes(first)}
|
||||
for notes, source in window_variants(joint, seam - width, seam + width, harmony, sequences, 'join')[:generator.solo.policy.DEFAULT.join_alternatives]:
|
||||
left = tuple(n for n in notes if n.event_id in left_ids)
|
||||
right = tuple(n for n in notes if n.event_id not in left_ids)
|
||||
if any(n.end > seam for n in left) or any(n.start < seam for n in right):
|
||||
continue
|
||||
edit = generator.solo.types.DraftEdit('phrase_join', seam - width, seam + width, tuple(n.event_id for n in combined if n not in notes), tuple(n.event_id for n in notes if n not in combined), source)
|
||||
if not edit.removed:
|
||||
continue
|
||||
revised_left = checked_phrase(first.plan, first.draft, left, first.state.treatments, incoming, harmony, tempo, (*first.edits, edit))
|
||||
if not revised_left:
|
||||
continue
|
||||
revised_right = checked_phrase(second.plan, second.draft, right, second.state.treatments, revised_left.state, harmony, tempo, (*second.edits, edit))
|
||||
context.diagnostics['pair_revision_attempts'] = context.diagnostics.get('pair_revision_attempts', 0) + 1
|
||||
if revised_right:
|
||||
realized = (*emitted_notes(revised_left), *emitted_notes(revised_right))
|
||||
score = revised_right.score + generator.solo.drafts.relationship_features(draft, realized)['join_coherence']
|
||||
candidates.append((score, revised_left, revised_right))
|
||||
_, left, right = min(candidates, key=lambda item: (item[0], len(item[1].edits) + len(item[2].edits)))
|
||||
if left is not first:
|
||||
context.diagnostics['pair_revisions_accepted'] = context.diagnostics.get('pair_revisions_accepted', 0) + 1
|
||||
return left, right
|
||||
|
||||
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
|
||||
context = context if context is not None else SearchContext()
|
||||
key = (plan, previous)
|
||||
# Keep resources alive while identity-keyed cache entries exist. A later
|
||||
# library cannot accidentally reuse the identity of a collected object.
|
||||
context.resources[id(sequences)] = sequences
|
||||
context.resources[id(rhythms)] = rhythms
|
||||
pool_key = (plan, previous, generator.solo.policy.stable_rank('harmonic-cache', choices), tempo, id(sequences), id(rhythms))
|
||||
key = (*pool_key, context.revise)
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits'] += 1
|
||||
return context.results[key]
|
||||
started = time.monotonic()
|
||||
context.diagnostics['searches'] += 1
|
||||
harmony = generator.solo.harmonic.Harmony(choices)
|
||||
incoming = previous if isinstance(previous, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=previous)
|
||||
continuing = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
|
||||
initial = incoming if continuing else generator.solo.types.ContinuationState(plan.passage_id, last=incoming.last, treatments=plan.treatments)
|
||||
backbones = plan.anchor_options or tuple([()])
|
||||
# A caller explicitly scheduling quotation owns its rhythm and pitches. Such
|
||||
# plans must provide compatible anchors; no automatic rewrite is permitted.
|
||||
states = [BeamState(0, (), dataclasses.replace(initial, backbone=i), initial.travel, initial.fidelity) for i in ([initial.backbone] if continuing else range(len(backbones)))]
|
||||
if plan not in context.pools:
|
||||
context.pools[plan] = cells(plan, measures, rhythms, choices)
|
||||
evaluation_plan = dataclasses.replace(plan, start=plan.passage_start if plan.passage_start is not None else plan.start, end=plan.passage_end or plan.end)
|
||||
last_error = ''
|
||||
for cell in context.pools[plan]:
|
||||
evaluation = generator.solo.scoring.Evaluation(evaluation_plan, choices, cell.end)
|
||||
following, pools = [], {}
|
||||
for state in states:
|
||||
music = state.music
|
||||
anchors = backbones[music.backbone]
|
||||
reference = generator.solo.motifs.reference(plan, cell.gesture, music.motifs)
|
||||
# Concrete results depend on pitch, fingering, motif, anchor progress,
|
||||
# rhythm history and unresolved treatments. Abstract rhythms alone cache
|
||||
# across unrelated incoming lines.
|
||||
pool_key = (music, anchors)
|
||||
if pool_key not in pools:
|
||||
pool = candidates(cell, plan, choices, sequences, music, anchors, harmony)
|
||||
context.diagnostics['generated_candidates'] += len(pool)
|
||||
for candidate in pool:
|
||||
family = note_family(candidate.notes)
|
||||
counts = context.diagnostics['candidate_families']
|
||||
counts[family] = counts.get(family, 0) + 1
|
||||
pools[pool_key] = generator.solo.sequences.diverse(pool, lambda c: (c.rank, c.source), lambda c: (len(c.notes), note_family(c.notes), bool(c.notes and c.notes[0].continuation)), CELL_LIMIT)
|
||||
for candidate in pools[pool_key]:
|
||||
context.diagnostics['evaluated_extensions'] += 1
|
||||
placed = generator.solo.placement.place(candidate.notes, tempo, music.last)
|
||||
if placed is None:
|
||||
continue
|
||||
comfort, notes = placed
|
||||
canonical = generator.solo.types.sounding_notes((*music.notes, *notes))
|
||||
completed = cell.end == (plan.passage_end or plan.end)
|
||||
harmonic = harmony.evaluate(canonical, candidate.treatments, until=cell.end, complete=completed)
|
||||
valid, error = generator.solo.harmonic.anchors_valid(anchors, canonical, cell.end, complete=completed, closure=plan.closure)
|
||||
if not harmonic.valid or not valid:
|
||||
last_error = harmonic.error or error
|
||||
continue
|
||||
if plan.target and (plan.target.latest < cell.end or cell.end == plan.end) and not target_ok(canonical, plan.target):
|
||||
last_error = 'legacy destination was not reached'
|
||||
continue
|
||||
motifs, fidelity = music.motifs, state.fidelity
|
||||
if cell.gesture and cell.end == cell.gesture.end:
|
||||
valid, mismatch, identity = generator.solo.motifs.complete(cell.gesture, reference, canonical, harmony)
|
||||
if not valid:
|
||||
last_error = f'{cell.gesture.id}: insufficient evidence of {cell.gesture.treatment} from {reference.id}'
|
||||
continue
|
||||
fidelity += (mismatch,)
|
||||
if identity:
|
||||
motifs += (identity,)
|
||||
if not evaluation.feasible(canonical):
|
||||
last_error = 'planned activity, sustain or space not fulfilled'
|
||||
continue
|
||||
features = evaluation.features(canonical)
|
||||
features['fidelity'] = (sum(fidelity) + candidate.fidelity) / (len(fidelity) + 1)
|
||||
features['harmonic_exposure'] = harmonic.exposure
|
||||
features['connection'] = min(1, abs(canonical[0].pitch.number() - incoming.last.pitch.number()) / 12) if canonical and incoming.last and not continuing else 0
|
||||
features['movement'] = min(1, (state.travel + comfort) / max(1, len(canonical)) / 2000)
|
||||
if cell.gesture:
|
||||
recent = tuple(n for n in canonical if cell.gesture.start <= n.start < cell.gesture.end)
|
||||
intended_repeats = sum(a.pitch == b.pitch == c.pitch and a.start == x.start - cell.gesture.start and b.start == y.start - cell.gesture.start and c.start == z.start - cell.gesture.start for a, b, c in zip(reference.notes, reference.notes[1:], reference.notes[2:]) for x, y, z in zip(recent, recent[1:], recent[2:]) if x.pitch == y.pitch == z.pitch)
|
||||
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(canonical) - 2))
|
||||
total = generator.solo.scoring.value(features)
|
||||
last = canonical[-1] if canonical else music.last
|
||||
progress = tuple(a.id for a in anchors if generator.solo.harmonic.anchor_note(a, canonical))
|
||||
direction = (last.pitch.number() > music.last.pitch.number()) - (last.pitch.number() < music.last.pitch.number()) if last and music.last else music.direction
|
||||
carried = generator.solo.types.ContinuationState(plan.passage_id, canonical, motifs, candidate.treatments, progress, direction, last, music.backbone, fidelity, state.travel + comfort)
|
||||
decision = generator.solo.types.Decision(cell.start, cell.end, candidate.source, candidate.treatment, notes, candidate.fidelity, cell.gesture.id if cell.gesture else '', reference.id)
|
||||
following.append(BeamState(total, (*state.decisions, decision), carried, state.travel + comfort, fidelity))
|
||||
if not following:
|
||||
raise SearchExhausted(f'{plan.id}, beats {cell.start + 1}-{cell.end + 1}: bounded solo search exhausted; {last_error or "no eligible continuation"}; {BEAM_WIDTH} states / {CELL_LIMIT} extensions (not proof that the passage is unplayable)')
|
||||
|
||||
def rank(state):
|
||||
return state.score, tuple((n.pitch, n.position, n.start, n.end) for n in state.music.notes)
|
||||
|
||||
unique = {}
|
||||
for state in sorted(following, key=rank):
|
||||
unique.setdefault(state.music, state)
|
||||
# A rolling one-gesture window keeps the longer plan bounded. Relevant
|
||||
# motif/anchor/treatment state survives; a matching last fret is not identity.
|
||||
limit = generator.solo.policy.DEFAULT.alternatives if cell.gesture and cell.end == cell.gesture.end else BEAM_WIDTH
|
||||
states = list(generator.solo.sequences.diverse(unique.values(), rank, lambda s: (s.music.backbone, note_family(s.music.notes[-4:]), s.music.last.pitch if s.music.last else None), limit))
|
||||
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
|
||||
selected = generator.solo.sequences.diverse(states, lambda s: (s.score, tuple(n.pitch for n in s.music.notes)), lambda s: (s.music.backbone, note_family(s.music.notes[-4:])), generator.solo.policy.DEFAULT.alternatives)
|
||||
for state in selected:
|
||||
family = note_family(state.music.notes[-4:])
|
||||
harmony = generator.solo.harmonic.Harmony(choices)
|
||||
plan = legacy_target_plan(plan, choices)
|
||||
if pool_key in context.pools:
|
||||
selected = context.pools[pool_key]
|
||||
context.diagnostics['reused_draft_pools'] = context.diagnostics.get('reused_draft_pools', 0) + 1
|
||||
else:
|
||||
constructions = generator.solo.drafts.rhythms(plan, incoming, measures, choices, rhythms, context.batch)
|
||||
complete, failure = [], ''
|
||||
for rhythm in constructions:
|
||||
paths, error = draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, context)
|
||||
failure = error or failure
|
||||
context.diagnostics['generated_candidates'] += len(paths)
|
||||
for notes, cost, contracts in paths:
|
||||
family = note_family(notes)
|
||||
counts = context.diagnostics['candidate_families']
|
||||
counts[family] = counts.get(family, 0) + 1
|
||||
realizations = [notes]
|
||||
if notes and incoming.last and incoming.last.end == notes[0].start and incoming.last.pitch == notes[0].pitch and notes[0].role != 'quote' and not any(a.onset_required and a.earliest <= notes[0].start <= a.latest for a in rhythm.anchors):
|
||||
realizations.append((dataclasses.replace(notes[0], continuation=True), *notes[1:]))
|
||||
for actual in realizations:
|
||||
draft = make_draft(rhythm, actual, plan, harmony, incoming)
|
||||
phrase = checked_phrase(plan, draft, actual, contracts, incoming, harmony, tempo)
|
||||
if phrase:
|
||||
complete.append(dataclasses.replace(phrase, score=phrase.score + 0.1 * cost))
|
||||
if not complete:
|
||||
raise SearchExhausted(f'{plan.id}: bounded phrase draft search exhausted; {failure or "no complete draft satisfies timing, anchors, treatments and motif contracts"}; {len(constructions)} constructions / {BEAM_WIDTH} pitch states (not proof that the phrase is unplayable)')
|
||||
context.diagnostics['construction_seconds'] = context.diagnostics.get('construction_seconds', 0) + time.monotonic() - started
|
||||
selected = generator.solo.sequences.diverse(complete, lambda p: (p.score, p.draft.id, tuple(n.pitch for n in p.draft.notes)), lambda p: (p.draft.relationship, note_family(p.draft.notes), p.anchors[-1].duration if p.anchors else None), generator.solo.policy.DEFAULT.phrase_alternatives)
|
||||
selected = tuple(elaborate(p, incoming, harmony, sequences, tempo, context) for p in selected)
|
||||
context.pools[pool_key] = selected
|
||||
input_id = generator.solo.policy.stable_rank(plan.seed, (plan, previous, choices))[:16]
|
||||
pool_id = generator.solo.policy.stable_rank(plan.seed, tuple((p.draft.id, emitted_notes(p)) for p in selected))
|
||||
context.diagnostics.setdefault('draft_pool_fingerprints', {})[input_id] = pool_id
|
||||
result = []
|
||||
for phrase in selected:
|
||||
if context.revise:
|
||||
phrase = revise_joins(phrase, incoming, harmony, sequences, tempo, context)
|
||||
result.append(phrase)
|
||||
result = tuple(sorted(result, key=lambda p: (p.score, p.draft.id)))
|
||||
for phrase in result:
|
||||
family = note_family(emitted_notes(phrase))
|
||||
counts = context.diagnostics['surviving_families']
|
||||
counts[family] = counts.get(family, 0) + 1
|
||||
result = tuple(generator.solo.types.Phrase(plan, state.decisions, state.score, state.music, backbones[state.music.backbone]) for state in selected)
|
||||
context.results[key] = result
|
||||
return result
|
||||
|
||||
|
||||
@@ -11,13 +11,14 @@ import generator.scales.selection
|
||||
import generator.scales.timeline
|
||||
import generator.solo.types
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.drafts
|
||||
|
||||
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str:
|
||||
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
|
||||
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.']
|
||||
if solo.diagnostics:
|
||||
lines.append('Composition diagnostics: ' + json.dumps(solo.diagnostics, sort_keys=True))
|
||||
lines.append('Composition diagnostics: ' + json.dumps({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}, sort_keys=True))
|
||||
detail = structure(solo)
|
||||
for passage in detail['passages']:
|
||||
lines.append(f"Melodic passage {passage['id']}: {passage['start']}-{passage['end']}; phrases {', '.join(passage['phrases'])}")
|
||||
@@ -26,6 +27,13 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
|
||||
plan = phrase.plan
|
||||
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
|
||||
lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}')
|
||||
if phrase.draft:
|
||||
draft = phrase.draft
|
||||
lines.append(f' Complete draft {draft.id}: {draft.relationship}; home {draft.home.id if draft.home else "none"}; {len(draft.notes)} attacks; rests {draft.rests()}')
|
||||
for transform in draft.transformations:
|
||||
lines.append(f' Development {transform.id}: {transform.operation}; {transform.start}-{transform.end}; parent {transform.parent}; home {transform.home}; preserves {transform.preserves}')
|
||||
for edit in phrase.edits:
|
||||
lines.append(f' Revision {edit.kind}: {edit.start}-{edit.end}; {edit.source}; replaced {edit.removed}; realized {edit.added}')
|
||||
for passage in plan.passages:
|
||||
lines.append(f' Activity {passage.start}-{passage.end}: {passage.intent}; attacks/beat {float(passage.activity):.3f}; occupancy {float(passage.occupancy):.2f}')
|
||||
for landmark in plan.landmarks:
|
||||
@@ -81,5 +89,8 @@ def structure(solo):
|
||||
event = generator.solo.harmonic.anchor_note(anchor, notes)
|
||||
anchors.append({**rational(dataclasses.asdict(anchor)), 'pitch': anchor.pitch.label(), 'completed': event is not None, 'actual_start': str(event.start) if event else None, 'actual_end': str(event.end) if event else None})
|
||||
motifs = tuple(m for m in phrase.state.motifs if phrase.plan.start <= m.source_start < phrase.plan.end) if phrase.state else ()
|
||||
phrases.append({'id': phrase.plan.id, 'passage': phrase.plan.passage_id, 'start': str(phrase.plan.start), 'end': str(phrase.plan.end), 'ending': phrase.plan.ending, 'closure': phrase.plan.closure, 'ending_completed': valid, 'ending_error': reason, 'anchors': anchors, 'motif_lineage': rational([dataclasses.asdict(m) for m in motifs])})
|
||||
entry = {'id': phrase.plan.id, 'passage': phrase.plan.passage_id, 'start': str(phrase.plan.start), 'end': str(phrase.plan.end), 'ending': phrase.plan.ending, 'closure': phrase.plan.closure, 'ending_completed': valid, 'ending_error': reason, 'anchors': anchors, 'motif_lineage': rational([dataclasses.asdict(m) for m in motifs])}
|
||||
if phrase.draft:
|
||||
entry.update({'draft': rational(dataclasses.asdict(phrase.draft)), 'draft_rests': rational(phrase.draft.rests()), 'edits': rational([dataclasses.asdict(e) for e in phrase.edits]), 'relationships': generator.solo.drafts.relationship_features(phrase.draft, tuple(n for d in phrase.decisions for n in d.notes)), 'home': phrase.state.home.id if phrase.state and phrase.state.home else None, 'current_development': phrase.state.development.id if phrase.state and phrase.state.development else None})
|
||||
phrases.append(entry)
|
||||
return {'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'harmonic_roles': harmony.describe(solo.notes(), contracts), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
|
||||
|
||||
@@ -33,6 +33,7 @@ class Note:
|
||||
role: str = 'continuation'
|
||||
position: generator.fretboard.TabPosition | None = None
|
||||
continuation: bool = False
|
||||
event_id: str = ''
|
||||
|
||||
def sounding_notes(notes):
|
||||
"""Collapse explicitly continued attacks, never adjacent repeated pitches."""
|
||||
@@ -130,6 +131,57 @@ class ContinuationState:
|
||||
backbone: int = 0
|
||||
fidelity: tuple[float, ...] = ()
|
||||
travel: int = 0
|
||||
home: RealizedMotif | None = None
|
||||
development: RealizedMotif | None = None
|
||||
themes: tuple[RealizedMotif, ...] = ()
|
||||
selected_anchors: tuple[Anchor, ...] = ()
|
||||
ending: str = ''
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Transformation:
|
||||
id: str
|
||||
parent: str
|
||||
home: str
|
||||
operation: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
events: tuple[str, ...]
|
||||
rhythm: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
|
||||
directions: tuple[int, ...] = ()
|
||||
preserves: tuple[str, ...] = ('rhythm', 'opening_contour')
|
||||
source: str = ''
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class DraftEdit:
|
||||
kind: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
removed: tuple[str, ...]
|
||||
added: tuple[str, ...]
|
||||
source: str = ''
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MelodicDraft:
|
||||
id: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
notes: tuple[Note, ...]
|
||||
transformations: tuple[Transformation, ...]
|
||||
structural: tuple[str, ...]
|
||||
protected: tuple[str, ...]
|
||||
anchors: tuple[Anchor, ...]
|
||||
home: RealizedMotif | None
|
||||
relationship: str
|
||||
joins: tuple[fractions.Fraction, ...] = ()
|
||||
def rests(self):
|
||||
cursor, result = self.start, []
|
||||
for note in self.notes:
|
||||
if note.start > cursor:
|
||||
result.append((cursor, note.start))
|
||||
cursor = max(cursor, note.end)
|
||||
if cursor < self.end:
|
||||
result.append((cursor, self.end))
|
||||
return tuple(result)
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MelodicPassagePlan:
|
||||
@@ -190,6 +242,8 @@ class Phrase:
|
||||
score: float = 0
|
||||
state: ContinuationState | None = None
|
||||
anchors: tuple[Anchor, ...] = ()
|
||||
draft: MelodicDraft | None = None
|
||||
edits: tuple[DraftEdit, ...] = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Solo:
|
||||
|
||||
@@ -34,6 +34,8 @@ class ArrangementPickupTests(unittest.TestCase):
|
||||
generator.model.validate_document(result)
|
||||
measures = result['staves'][0]['measures']
|
||||
self.assertEqual(len(measures), 176)
|
||||
self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 177)])
|
||||
self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137', '169'])
|
||||
self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 704)
|
||||
timelines.append([m['duration'] for m in measures])
|
||||
if path.stem == 'autumn_leaves' and part in ('vocal', 'electric'):
|
||||
|
||||
@@ -124,14 +124,15 @@ class ElectricVoicingTests(unittest.TestCase):
|
||||
self.assertEqual(measures, original)
|
||||
if section.role != 'accompaniment':
|
||||
continue
|
||||
for m, old in zip(measures, original):
|
||||
for offset, (m, old) in enumerate(zip(measures, original)):
|
||||
source_number = source['staves'][0]['measures'][section.source_measure_index + offset]['number']
|
||||
for voice, old_voice in zip(m['voices'], old['voices']):
|
||||
self.assertEqual([{k: v for k, v in n.items() if k != 'pitches'} for n in voice['notes']], [{k: v for k, v in n.items() if k != 'pitches'} for n in old_voice['notes']])
|
||||
for n in voice['notes']:
|
||||
if not n['pitches'] or m['number'] not in ('4', '12', '22', '24'):
|
||||
if not n['pitches'] or source_number not in ('4', '12', '22', '24'):
|
||||
continue
|
||||
expected = (3, 3, 3, 3) if m['number'] == '22' else (3, 5, 5, 5)
|
||||
expected = (3, 3, 3, 3) if source_number == '22' else (3, 5, 5, 5)
|
||||
self.assertEqual(tuple(f for _, f in sorted(shape(n), reverse=True)), expected)
|
||||
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in n['pitches']))
|
||||
checked.add(m['number'])
|
||||
checked.add(source_number)
|
||||
self.assertEqual(checked, {'4', '12', '22', '24'})
|
||||
|
||||
@@ -33,6 +33,13 @@ def notes(document, measure=0):
|
||||
return document['staves'][0]['measures'][measure]['voices'][0]['notes']
|
||||
|
||||
class MusicXMLTests(unittest.TestCase):
|
||||
def test_export_keeps_arrangement_measure_number_in_an_excerpt(self):
|
||||
source = parse(xml_score(xml_note()))
|
||||
source['staves'][0]['measures'][0]['number'] = '105'
|
||||
root = generator.musicxml_writer.musicxml_tree(source).getroot()
|
||||
self.assertEqual(root.find('part/measure').get('number'), '105')
|
||||
restored = parse(xml.etree.ElementTree.tostring(root, encoding='unicode'))
|
||||
self.assertEqual(restored['staves'][0]['measures'][0]['number'], '105')
|
||||
def test_obsolete_style_hints_are_rejected(self):
|
||||
for value in ('Medium Swing', 'Med.', 'Bossa Nova', 'Unrecognized style', ''):
|
||||
with self.subTest(value=value), self.assertRaisesRegex(generator.musicxml.MusicXMLError, "fixture.xml: unsupported Vamp property 'vamp:style'"):
|
||||
|
||||
@@ -83,7 +83,7 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
dense = ''.join(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64))
|
||||
source.write_text(tests.test_musicxml.xml_score(dense, divisions=16))
|
||||
with self.assertRaisesRegex(generator.engraving.types.EngravingError, r'songs/fixture_electric.json: measure 1 .*complete measure exceeds'):
|
||||
with self.assertRaisesRegex(generator.engraving.types.EngravingError, r'songs/fixture_electric.json: measure 3 .*complete measure exceeds'):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@ class SoloTests(unittest.TestCase):
|
||||
self.assertEqual(result.phrases[-1].plan.end, fractions.Fraction(43, 2))
|
||||
def test_search_exhaustion_does_not_claim_impossibility(self):
|
||||
source = song()
|
||||
with unittest.mock.patch('generator.solo.realization.candidates', return_value=[]), self.assertRaisesRegex(generator.solo.realization.SearchExhausted, 'not proof'):
|
||||
with unittest.mock.patch('generator.solo.realization.draft_pitch_paths', return_value=((), 'no route within the draft budget')), self.assertRaisesRegex(generator.solo.realization.SearchExhausted, 'not proof'):
|
||||
compose(source)
|
||||
def test_time_aware_placement_does_not_penalize_absolute_frets(self):
|
||||
p = generator.solo.types.Pitch('c', fractions.Fraction(), 4)
|
||||
|
||||
@@ -80,7 +80,7 @@ class DevelopmentTests(unittest.TestCase):
|
||||
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 < 4 < n.end for n in notes) or 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):
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Construction contracts, not claims about listener preference."""
|
||||
import dataclasses
|
||||
import fractions
|
||||
import unittest
|
||||
|
||||
import generator.rhythm
|
||||
import generator.solo.drafts
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.realization
|
||||
import generator.solo.scoring
|
||||
import generator.solo.types
|
||||
import tests.rhythm_fixtures
|
||||
import tests.solo_fixtures
|
||||
import tests.test_solo
|
||||
import tests.test_solo_development
|
||||
import tests.test_solo_passages
|
||||
|
||||
F = fractions.Fraction
|
||||
note = tests.test_solo_passages.note
|
||||
|
||||
def illustration():
|
||||
"""User-specified four-bar example; additional fixtures await listening review."""
|
||||
events = ((0, 1, 'd', 0, 4), (2, 3, 'f', 0, 4), (3, F(7, 2), 'g', 0, 4), (F(7, 2), 4, 'a', 0, 4),
|
||||
(4, 5, 'b', -1, 4), (5, 6, 'g', 0, 4), (6, F(13, 2), 'a', 0, 4), (F(13, 2), 7, 'g', 0, 4), (7, 8, 'e', 0, 4),
|
||||
(8, 9, 'f', 0, 4), (9, F(19, 2), 'g', 0, 4), (F(19, 2), 10, 'a', 0, 4), (10, 11, 'c', 0, 5), (11, 13, 'a', 0, 4),
|
||||
(13, F(27, 2), 'g', 0, 4), (F(27, 2), 14, 'f', 0, 4), (14, 16, 'd', 0, 4))
|
||||
notes = tuple(dataclasses.replace(note(a, b, pitch, alter, octave), event_id=f'example_{i}') for i, (a, b, pitch, alter, octave) in enumerate(events))
|
||||
choices = tests.test_solo_passages.choices(('Gm7', 'C7', 'FM7', 'BbM7'))
|
||||
harmony = generator.solo.harmonic.Harmony(choices)
|
||||
home = generator.solo.realization.make_identity('home', 'user_example', notes[1:5], F(2), F(5), 'statement', harmony)
|
||||
groups = []
|
||||
for identifier, operation, selected in (('home', 'statement', notes[1:5]), ('extension', 'extend', notes[9:13])):
|
||||
start = selected[0].start
|
||||
groups.append(generator.solo.types.Transformation(identifier, 'user_example' if identifier == 'home' else 'home', 'home', operation, start, selected[-1].end, tuple(n.event_id for n in selected), tuple((n.start - start, n.end - start) for n in selected), (1, 1, 1)))
|
||||
anchor = generator.solo.types.Anchor('terminal', F(14), F(14), notes[-1].pitch, frozenset({2}), (55, 76), F(2), 'terminal', 'BbM7', hold_until=F(16))
|
||||
draft = generator.solo.types.MelodicDraft('illustration', F(), F(16), notes, tuple(groups), (notes[-1].event_id,), (), (anchor,), home, 'statement', (F(5), F(8)))
|
||||
return draft, choices
|
||||
|
||||
class DraftRepresentationTests(unittest.TestCase):
|
||||
def test_user_phrase_is_valid_and_contains_seventeen_attacks(self):
|
||||
draft, choices = illustration()
|
||||
harmony = generator.solo.harmonic.Harmony(choices)
|
||||
self.assertEqual(len(draft.notes), 17)
|
||||
self.assertEqual(draft.rests(), ((1, 2),))
|
||||
self.assertTrue(harmony.evaluate(draft.notes, complete=True).valid)
|
||||
self.assertTrue(generator.solo.drafts.contract_valid(draft, draft.notes)[0])
|
||||
self.assertTrue(generator.solo.harmonic.anchors_valid(draft.anchors, draft.notes, 16, complete=True, closure='closed')[0])
|
||||
self.assertTrue(any(t.start < 4 < t.end for t in draft.transformations))
|
||||
def test_partition_preserves_draft_relationships_and_harmonic_roles(self):
|
||||
draft, choices = illustration()
|
||||
whole = draft.notes
|
||||
split = []
|
||||
for n in whole:
|
||||
points = sorted({n.start, n.end} | {F(t) for t in range(1, 16) if n.start < t < n.end})
|
||||
split.extend(dataclasses.replace(n, start=a, end=b, continuation=i > 0) for i, (a, b) in enumerate(zip(points, points[1:])))
|
||||
self.assertEqual(generator.solo.types.sounding_notes(split), whole)
|
||||
self.assertEqual(generator.solo.drafts.relationship_features(draft, whole), generator.solo.drafts.relationship_features(draft, split))
|
||||
self.assertTrue(generator.solo.drafts.contract_valid(draft, split)[0])
|
||||
harmony = generator.solo.harmonic.Harmony(choices)
|
||||
self.assertEqual(harmony.describe(whole), harmony.describe(split))
|
||||
def test_disconnected_alternative_breaks_declared_rise_at_same_density(self):
|
||||
draft, _ = illustration()
|
||||
changed = list(draft.notes)
|
||||
changed[2] = dataclasses.replace(changed[2], pitch=note(0, 1, 'e').pitch)
|
||||
self.assertEqual(len(changed), len(draft.notes))
|
||||
self.assertEqual(sum(n.end - n.start for n in changed), sum(n.end - n.start for n in draft.notes))
|
||||
valid, reason = generator.solo.drafts.contract_valid(draft, changed)
|
||||
self.assertFalse(valid)
|
||||
self.assertIn('contour', reason)
|
||||
self.assertGreater(generator.solo.drafts.relationship_features(draft, changed)['direction'], 0)
|
||||
def test_catalog_keeps_cross_bar_sustain_as_one_event(self):
|
||||
event = generator.rhythm.Event
|
||||
bar = generator.rhythm.Bar
|
||||
snippet = generator.rhythm.Snippet('ties', '4/4', (bar((event(F(), F(3), False, 'half'), event(F(3), F(1), True, 'quarter')), F(4)), bar((event(F(), F(1), True, 'quarter', continuation=True), event(F(1), F(3), True, 'half')), F(4))))
|
||||
catalog = generator.solo.drafts.catalog(generator.rhythm.Library((snippet,)), '4/4')
|
||||
self.assertEqual(catalog[0][1], ((3, 5), (5, 8)))
|
||||
def test_extension_reopens_only_an_unprotected_provisional_tail(self):
|
||||
slot = generator.solo.drafts.Slot('tail', F(2), F(4), 0, 2)
|
||||
result, cursor = generator.solo.drafts.reopen((slot,), F(4))
|
||||
self.assertEqual((result[-1].end, cursor), (F(7, 2), F(7, 2)))
|
||||
for protected in (dataclasses.replace(slot, protected=True), dataclasses.replace(slot, forced=note(0, 1, 'e').pitch)):
|
||||
self.assertEqual(generator.solo.drafts.reopen((protected,), F(4)), ((protected,), F(4)))
|
||||
def test_existing_terminal_contract_is_not_retimed(self):
|
||||
draft, choices = illustration()
|
||||
plan = dataclasses.replace(tests.test_solo_development.plan(), end=F(16), target=generator.solo.types.TargetEvent(F(14), F(14), frozenset({2}), 'BbM7', 'explicit ending'))
|
||||
self.assertEqual(generator.solo.drafts.ending_options(draft.anchors, plan, choices), (draft.anchors,))
|
||||
def test_ending_alternatives_vary_timing_without_changing_destination(self):
|
||||
draft, choices = illustration()
|
||||
plan = dataclasses.replace(tests.test_solo_development.plan(), end=F(16))
|
||||
endings = generator.solo.drafts.ending_options(draft.anchors, plan, choices)
|
||||
self.assertEqual({a[-1].duration for a in endings}, {F(1, 2), F(1), F(2), F(3)})
|
||||
self.assertTrue(all(a[-1].pitch == draft.anchors[-1].pitch and a[-1].earliest + a[-1].duration == 16 for a in endings))
|
||||
def test_revision_cannot_change_terminal_hold_or_identifying_rhythm(self):
|
||||
draft, _ = illustration()
|
||||
for index in (2, len(draft.notes) - 1):
|
||||
notes = list(draft.notes)
|
||||
notes[index] = dataclasses.replace(notes[index], end=notes[index].end - F(1, 4))
|
||||
self.assertFalse(generator.solo.drafts.contract_valid(draft, notes)[0])
|
||||
def test_unrelated_single_note_is_not_perfect_development(self):
|
||||
draft, _ = illustration()
|
||||
event = draft.notes[0]
|
||||
transform = generator.solo.types.Transformation('short', 'home', 'home', 'extend', event.start, event.end, (event.event_id,), ((F(), F(1)),))
|
||||
candidate = dataclasses.replace(draft, notes=(event,), transformations=(transform,), structural=(), anchors=(), joins=())
|
||||
self.assertEqual(generator.solo.drafts.relationship_features(candidate, candidate.notes)['development'], 1)
|
||||
def test_diminution_and_broadening_keep_exact_timing_and_event_order(self):
|
||||
pattern = ((F(), F(1)), (F(3, 2), F(2)), (F(2), F(5, 2)))
|
||||
transformed, length = generator.solo.drafts.develop_timing(pattern, F(3), F(1, 2), F(3, 4), True)
|
||||
self.assertEqual(len(transformed), len(pattern))
|
||||
self.assertTrue(all(b - a >= F(1, 2) for a, b in transformed))
|
||||
self.assertTrue(all(b <= c for (_, b), (c, _) in zip(transformed, transformed[1:])))
|
||||
self.assertLess(length, 3)
|
||||
def test_occupancy_goal_cannot_create_arbitrary_tuplet_durations(self):
|
||||
pattern = ((F(), F(1, 2)), (F(1), F(3, 2)), (F(2), F(5, 2)))
|
||||
for occupancy in (F(3, 4), F(9, 10)):
|
||||
for unit in (F(1, 2), F(1), F(2)):
|
||||
transformed, length = generator.solo.drafts.develop_timing(pattern, F(3), unit, occupancy, True)
|
||||
self.assertTrue(all((a * 4).denominator == (b * 4).denominator == 1 for a, b in transformed))
|
||||
self.assertEqual((length * 4).denominator, 1)
|
||||
def test_pending_treatment_constrains_the_new_draft_pitch_search(self):
|
||||
choices = tests.test_solo_passages.choices(('Gm7', 'C7'))
|
||||
harmony = generator.solo.harmonic.Harmony(choices)
|
||||
held = note(2, F(9, 2), 'f')
|
||||
treatment = generator.solo.types.HarmonicTreatment('pending', 'suspension', 65, F(4), F(9, 2), 64, F(5))
|
||||
incoming = generator.solo.types.ContinuationState('p', (held,), treatments=(treatment,), last=held)
|
||||
plan = dataclasses.replace(tests.test_solo_development.plan(), start=F(9, 2), end=F(6))
|
||||
slot = generator.solo.drafts.Slot('resolve', F(9, 2), F(5), 0, 0)
|
||||
group = generator.solo.drafts.Group('g', 'continue', F(9, 2), F(6), 'prepared treatment')
|
||||
rhythm = generator.solo.drafts.RhythmDraft('r', (slot,), (group,), (), plan.motif, 'continue')
|
||||
paths, error = generator.solo.realization.draft_pitch_paths(rhythm, plan, choices, harmony, tests.solo_fixtures.library(), incoming, generator.solo.realization.SearchContext())
|
||||
self.assertTrue(paths, error)
|
||||
self.assertTrue(all(notes[0].pitch.number() == 64 for notes, _, _ in paths))
|
||||
self.assertTrue(all(harmony.evaluate((held, *notes), contracts, complete=True).valid for notes, _, contracts in paths))
|
||||
def test_join_search_can_rewrite_both_sides_and_preserve_outside_events(self):
|
||||
pitches = (('c', 4), ('g', 4), ('e', 4), ('c', 5), ('c', 4), ('f', 4), ('g', 4), ('e', 4))
|
||||
notes = tuple(dataclasses.replace(note(i, i + 1, letter, octave=octave), event_id=f'n{i}') for i, (letter, octave) in enumerate(pitches))
|
||||
plan = tests.test_solo_development.plan()
|
||||
harmony = generator.solo.harmonic.Harmony(tests.test_solo_development.choices())
|
||||
transform = generator.solo.types.Transformation('connecting_run', '', '', 'close', F(), F(8), tuple(n.event_id for n in notes), tuple((n.start, n.end) for n in notes), preserves=())
|
||||
draft = generator.solo.types.MelodicDraft('join_fixture', F(), F(8), notes, (transform,), ('n0', 'n7'), (), (), None, 'continue', (F(4),))
|
||||
incoming = generator.solo.types.ContinuationState('')
|
||||
original = generator.solo.realization.checked_phrase(plan, draft, notes, (), incoming, harmony, F(120))
|
||||
self.assertIsNotNone(original)
|
||||
revised = generator.solo.realization.improve_window(original, incoming, harmony, tests.solo_fixtures.library(), F(120), F(2), F(6), 'join', generator.solo.realization.SearchContext())
|
||||
actual = generator.solo.realization.emitted_notes(revised)
|
||||
self.assertLess(revised.score, original.score)
|
||||
self.assertTrue(any(a.pitch != b.pitch and a.start < 4 for a, b in zip(notes, actual)))
|
||||
self.assertTrue(any(a.pitch != b.pitch and a.start >= 4 for a, b in zip(notes, actual)))
|
||||
self.assertEqual((actual[0].pitch, actual[-1].pitch), (notes[0].pitch, notes[-1].pitch))
|
||||
self.assertTrue(harmony.evaluate(actual, complete=True).valid)
|
||||
def test_passage_boundary_does_not_count_a_held_note_as_a_new_attack(self):
|
||||
choices = tests.test_solo_development.choices()
|
||||
harmony = generator.solo.harmonic.Harmony(choices)
|
||||
placement = generator.solo.placement.place((note(0, 4, 'g'),), F(120))[1][0]
|
||||
incoming = generator.solo.types.ContinuationState('old', (placement,), last=placement)
|
||||
held = dataclasses.replace(note(4, 8, 'g'), continuation=True, event_id='hold')
|
||||
plan = dataclasses.replace(tests.test_solo_development.plan(), start=F(4), passage_id='new', passages=(generator.solo.types.ActivitySpan(F(4), F(8), 'active', F(1), F(3, 4)),))
|
||||
transform = generator.solo.types.Transformation('hold', '', '', 'close', F(4), F(8), ('hold',), ((F(), F(4)),), preserves=())
|
||||
draft = generator.solo.types.MelodicDraft('hold', F(4), F(8), (held,), (transform,), (), (), (), None, 'continue')
|
||||
phrase = generator.solo.realization.checked_phrase(plan, draft, (held,), (), incoming, harmony, F(120))
|
||||
self.assertIsNotNone(phrase)
|
||||
features = generator.solo.scoring.features(plan, (dataclasses.replace(placement, end=F(8)),), choices)
|
||||
self.assertEqual(phrase.score, generator.solo.scoring.value(features))
|
||||
def test_new_passage_carries_pending_but_not_completed_suspensions(self):
|
||||
treatment = generator.solo.types.HarmonicTreatment('prepared', 'suspension', 65, F(4), F(9, 2), 64, F(5))
|
||||
held, resolution = note(2, F(9, 2), 'f'), note(F(9, 2), 5, 'e')
|
||||
plan = dataclasses.replace(tests.test_solo_development.plan(), start=F(5), passage_id='next')
|
||||
incoming = generator.solo.types.ContinuationState('old', (held,), treatments=(treatment,), last=held)
|
||||
self.assertEqual(generator.solo.realization.inherited_treatments(plan, incoming), (treatment,))
|
||||
resolved = dataclasses.replace(incoming, notes=(held, resolution), last=resolution)
|
||||
self.assertEqual(generator.solo.realization.inherited_treatments(plan, resolved), ())
|
||||
self.assertEqual(generator.solo.realization.inherited_treatments(dataclasses.replace(plan, passage_id='old'), resolved), (treatment,))
|
||||
|
||||
class DraftCompositionTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.solo = tests.test_solo.compose(tests.test_solo.song(count=8, chords=('Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7')))
|
||||
def test_production_candidates_contain_complete_playable_drafts(self):
|
||||
harmony = generator.solo.harmonic.Harmony(self.solo.choices)
|
||||
previous = None
|
||||
for phrase in self.solo.phrases:
|
||||
draft = phrase.draft
|
||||
self.assertIsNotNone(draft)
|
||||
self.assertEqual((draft.start, draft.end), (phrase.plan.start, phrase.plan.end))
|
||||
prefix = (dataclasses.replace(previous, position=None),) if previous else ()
|
||||
self.assertTrue(harmony.evaluate((*prefix, *draft.notes), phrase.state.treatments, until=draft.end).valid)
|
||||
self.assertTrue(generator.solo.placement.place(draft.notes, self.solo.tempo, previous))
|
||||
self.assertTrue(draft.transformations)
|
||||
previous = phrase.state.last
|
||||
def test_home_survives_several_developments_and_phrase_boundary(self):
|
||||
first, second = self.solo.phrases
|
||||
self.assertEqual(first.state.home.id, second.state.home.id)
|
||||
self.assertTrue(all(t.home == first.state.home.id for p in self.solo.phrases for t in p.draft.transformations))
|
||||
self.assertNotEqual(second.state.development.id, second.state.home.id)
|
||||
self.assertEqual(second.state.selected_anchors, first.state.selected_anchors)
|
||||
def test_groups_cross_barlines_without_reference_timing_reset(self):
|
||||
groups = [t for p in self.solo.phrases for t in p.draft.transformations]
|
||||
self.assertTrue(any(t.start < beat < t.end for t in groups for beat in range(4, 32, 4)))
|
||||
for phrase in self.solo.phrases:
|
||||
by_id = {n.event_id: n for n in phrase.draft.notes}
|
||||
for transform in phrase.draft.transformations:
|
||||
self.assertEqual(tuple((by_id[e].start - transform.start, by_id[e].end - transform.start) for e in transform.events), transform.rhythm)
|
||||
def test_new_passage_candidates_explicitly_recall_or_contrast(self):
|
||||
prior = self.solo.phrases[0].state
|
||||
plan = dataclasses.replace(self.solo.phrases[0].plan, id='new', passage_id='new_passage')
|
||||
rhythms = generator.solo.drafts.rhythms(plan, prior, tests.test_solo.song(count=8)['staves'][0]['measures'], self.solo.choices, tests.rhythm_fixtures.library())
|
||||
self.assertTrue(any(r.reference.id == prior.home.id and r.relationship in ('continue', 'answer', 'recall') for r in rhythms))
|
||||
self.assertTrue(all(r.relationship != 'statement' for r in rhythms))
|
||||
def test_cell_boundaries_are_not_phrase_rhythm_inputs(self):
|
||||
prior = generator.solo.types.ContinuationState('')
|
||||
plan = self.solo.phrases[0].plan
|
||||
measures = tests.test_solo.song(count=8)['staves'][0]['measures']
|
||||
first = generator.solo.drafts.rhythms(plan, prior, measures, self.solo.choices, tests.rhythm_fixtures.library())
|
||||
second = generator.solo.drafts.rhythms(dataclasses.replace(plan, gestures=(generator.solo.types.Gesture('arbitrary_cell', F(), F(3), 'continue', 'contour_reference'),)), prior, measures, self.solo.choices, tests.rhythm_fixtures.library())
|
||||
self.assertEqual(first, second)
|
||||
def test_full_realization_revalidates_each_accepted_revision(self):
|
||||
generator.solo.composer.validate(self.solo)
|
||||
for phrase in self.solo.phrases:
|
||||
notes = generator.solo.realization.emitted_notes(phrase)
|
||||
self.assertTrue(generator.solo.drafts.contract_valid(phrase.draft, notes)[0])
|
||||
self.assertTrue(all(1 <= n.position.fret <= 12 for n in notes))
|
||||
for edit in phrase.edits:
|
||||
self.assertLessEqual(edit.end - edit.start, 4 if edit.kind != 'elaboration' else phrase.plan.end - phrase.plan.start)
|
||||
def test_join_comparison_reuses_identical_upstream_draft_pool(self):
|
||||
plan = self.solo.phrases[0].plan
|
||||
measures = tests.test_solo.song(count=8)['staves'][0]['measures']
|
||||
context = generator.solo.realization.SearchContext(revise=False)
|
||||
arguments = (plan, measures, self.solo.choices, tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), self.solo.tempo)
|
||||
generator.solo.realization.alternatives(*arguments, context=context)
|
||||
fingerprints = dict(context.diagnostics['draft_pool_fingerprints'])
|
||||
constructed = context.diagnostics['generated_candidates']
|
||||
context.revise = True
|
||||
generator.solo.realization.alternatives(*arguments, context=context)
|
||||
self.assertEqual(context.diagnostics['generated_candidates'], constructed)
|
||||
self.assertEqual(context.diagnostics['draft_pool_fingerprints'], fingerprints)
|
||||
self.assertEqual(context.diagnostics['reused_draft_pools'], 1)
|
||||
@@ -9,6 +9,7 @@ import generator.harmony
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.rhythm
|
||||
import generator.score
|
||||
import tests.rhythm_fixtures
|
||||
import tests.test_musicxml
|
||||
import tests.test_parts
|
||||
@@ -187,6 +188,7 @@ class VoicingTests(unittest.TestCase):
|
||||
result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
|
||||
generator.model.validate_document(result)
|
||||
section, checked = '', set()
|
||||
source_index = generator.score.body_start_measure_index(source['staves'][0]['measures'])
|
||||
for measure in result['staves'][0]['measures']:
|
||||
for voice in measure['voices']:
|
||||
for note in voice['notes']:
|
||||
@@ -198,19 +200,20 @@ class VoicingTests(unittest.TestCase):
|
||||
section = measure.get('section_label') or section
|
||||
if section != 'Guitar Melody':
|
||||
continue
|
||||
original = next(m for m in source['staves'][0]['measures'] if m['number'] == measure['number'])
|
||||
original = source['staves'][0]['measures'][source_index]
|
||||
source_index += 1
|
||||
for note in measure['voices'][0]['notes']:
|
||||
if not note['pitches']:
|
||||
continue
|
||||
onset = generator.model.fraction(note['onset'])
|
||||
authored = next(n for n in original['voices'][0]['notes'] if generator.model.fraction(n['onset']) <= onset < generator.model.fraction(n['onset']) + generator.model.fraction(n['duration']))
|
||||
self.assertEqual(generator.model.pitch_number(note['pitches'][0]), generator.model.pitch_number(authored['pitches'][0]) + 12)
|
||||
if measure['number'] not in ('4', '12', '22', '24') or len(note['pitches']) == 1:
|
||||
if original['number'] not in ('4', '12', '22', '24') or len(note['pitches']) == 1:
|
||||
continue
|
||||
expected = (3, 3, 3, 3) if measure['number'] == '22' else (3, 5, 5, 5)
|
||||
expected = (3, 3, 3, 3) if original['number'] == '22' else (3, 5, 5, 5)
|
||||
frets = tuple(p['tablature']['fret'] for p in sorted(note['pitches'], key=lambda p: -p['tablature']['string']))
|
||||
self.assertEqual(frets, expected)
|
||||
self.assertEqual(generator.model.pitch_number(note['pitches'][0]), max(generator.model.pitch_number(p) for p in note['pitches']))
|
||||
checked.add(measure['number'])
|
||||
checked.add(original['number'])
|
||||
self.assertEqual(checked, {'4', '12', '22', '24'})
|
||||
self.assertEqual(source, before)
|
||||
|
||||
Reference in New Issue
Block a user