Soloing & Measures
ci / checks (push) Successful in 1h16m41s

This commit is contained in:
khodowany
2026-10-01 14:05:51 -05:00
parent a7b64c5361
commit 28fc67fba1
21 changed files with 1231 additions and 168 deletions
+6 -2
View File
@@ -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. 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 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: To inspect generated assets without building the full package, run:
```sh ```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. 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. 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.
+4
View File
@@ -125,6 +125,10 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
if generator.accompaniment.connect_continuations(result): if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result) generator.guitar.place_guitar(result)
generator.electric.reuse_voicings(voicing_links) 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) generator.fretboard.validate_document(result)
if scales and solos: if scales and solos:
report = generator.scales.mapping.annotate(result, solos, scales, source_name or document['metadata']['title']) report = generator.scales.mapping.annotate(result, solos, scales, source_name or document['metadata']['title'])
+1 -1
View File
@@ -241,7 +241,7 @@ def musicxml_tree(document: dict) -> xml.etree.ElementTree.ElementTree:
absolute = fractions.Fraction() absolute = fractions.Fraction()
previous_state = {} previous_state = {}
for index, measure in enumerate(staff['measures']): 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') write_barline(bar, measure, 'left')
times = [generator.model.fraction(measure['duration'])] 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')) times.extend(generator.model.fraction(n[field]) for v in measure['voices'] for n in v['notes'] for field in ('onset', 'duration'))
+3 -3
View File
@@ -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()) 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: 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:') lines.append('Scale map:')
previous = None previous = None
for choice in choices: 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.' 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: 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) lines.append(' ' + choice.reason)
previous = label previous = label
lines.extend(('', 'Chord detail:')) lines.extend(('', 'Chord detail:'))
@@ -28,7 +28,7 @@ def passage(part: str, solo, choices, library) -> str:
if bass := chord.get('bass'): if bass := chord.get('bass'):
required.append(generator.model.pitch_name(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] 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))) lines.append(' Chord: ' + generator.harmony.symbol(chord) + '; required pitches: ' + ', '.join(dict.fromkeys(required)))
if scale: if scale:
root = generator.model.pitch_number({**chord['root'], 'octave': 4}) % 12 root = generator.model.pitch_number({**chord['root'], 'octave': 4}) % 12
+2 -2
View File
@@ -85,7 +85,7 @@ def backing(baseline, foreground, part, rhythms, references, choices, initial=No
generator.model.validate_document(document) generator.model.validate_document(document)
return document['staves'][0]['measures'] return document['staves'][0]['measures']
def apply(source, parts, *, rhythms, scales, sequences, 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.""" """Compose once per song, replacing only already-placed solo section windows."""
analysis = generator.solo.analysis.analyze(source) analysis = generator.solo.analysis.analyze(source)
sections = regions(source) sections = regions(source)
@@ -103,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']] sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']]
if sounding: if sounding:
cutoff -= generator.model.fraction(pickup['duration']) - min(sounding) cutoff -= generator.model.fraction(pickup['duration']) - min(sounding)
solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial) solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial, revise=revise, context=(contexts or {}).get(part))
if solos is not None: if solos is not None:
solos.append(solo) solos.append(solo)
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part) foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
+88 -10
View File
@@ -5,6 +5,7 @@ import argparse
import array import array
import collections import collections
import copy import copy
import dataclasses
import fractions import fractions
import hashlib import hashlib
import html import html
@@ -26,6 +27,7 @@ import generator.scales.library
import generator.solo.arrangement import generator.solo.arrangement
import generator.solo.report import generator.solo.report
import generator.solo.sequences import generator.solo.sequences
import generator.solo.realization
STAGES = ('baseline', 'A', 'B', 'C', 'D') STAGES = ('baseline', 'A', 'B', 'C', 'D')
@@ -71,13 +73,14 @@ def fingerprint():
digest.update(path.read_bytes()) digest.update(path.read_bytes())
return digest.hexdigest() 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 destination = root / stage
if destination.exists(): if destination.exists():
raise ValueError(f'{destination}: snapshot already exists; choose a new output root') raise ValueError(f'{destination}: snapshot already exists; choose a new output root')
rhythms = generator.rhythm.load_library(pathlib.Path('data/rhythms')) rhythms, scales, sequences = libraries or resources()
scales = generator.scales.library.load_library(pathlib.Path('data/scales'))
sequences = generator.solo.sequences.load_library(pathlib.Path('data/sequences'))
result = {'stage': stage, 'implementation': fingerprint(), 'songs': {}} result = {'stage': stage, 'implementation': fingerprint(), 'songs': {}}
try: try:
result['policy'] = importlib.import_module('generator.solo.policy').describe() 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) 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} parts = {part: generator.arrange.generate._arrange_document(document, part, plans) for part in generator.parts.PART_TYPES}
solos = [] 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} song = {'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'parts': parts, 'solos': {}, 'elapsed_seconds': time.monotonic() - started}
regions = generator.solo.arrangement.regions(document) regions = generator.solo.arrangement.regions(document)
for solo, index in zip(solos, (3, 4)): for solo, index in zip(solos, (3, 4)):
first, count, offset, length = regions[index] 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)} 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) print(path.stem, solo.part, json.dumps(song['solos'][solo.part]['metrics']), flush=True)
result['songs'][path.stem] = song result['songs'][path.stem] = song
destination.mkdir(parents=True) destination.mkdir(parents=True)
@@ -152,7 +180,7 @@ def mix(documents, tempo, root, cache):
return output return output
def render(root): 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} 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': []} cache, clips, manifest = {}, [], {'sample_rate': generator.playback.SAMPLE_RATE, 'stages': {}, 'clips': []}
for stage, snapshot in snapshots.items(): for stage, snapshot in snapshots.items():
@@ -163,7 +191,7 @@ def render(root):
raise ValueError(f'{song_name}: source changed between audition stages') raise ValueError(f'{song_name}: source changed between audition stages')
for part, solo in song['solos'].items(): for part, solo in song['solos'].items():
other = 'electric' if part == 'acoustic' else 'acoustic' 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: for label, offset, count in windows:
first = solo['first'] + offset first = solo['first'] + offset
foreground = excerpt(song['parts'][part], first, count) 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) samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
clips.append((path, samples)) 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']}) 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. # One common attenuation preserves relative loudness and identical backing.
peak = max(abs(value) for _, samples in clips for value in samples) peak = max(abs(value) for _, samples in clips for value in samples)
gain = min(1, 0.95 / peak) if peak else 1 gain = min(1, 0.95 / peak) if peak else 1
@@ -186,9 +232,33 @@ def render(root):
for path, samples in clips: for path, samples in clips:
generator.playback.write_pcm(path, (value * gain for value in samples)) generator.playback.write_pcm(path, (value * gain for value in samples))
(root / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') (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) 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): def write_index(root, snapshots):
labels = {'baseline': 'Original engine', 'A': 'Scoring repair', 'B': 'Activity shapes', 'C': 'Motifs and retrieval', 'D': 'Phrase lookahead'} 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>'] 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 = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_auditions')) parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_auditions'))
action = parser.add_mutually_exclusive_group(required=True) 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') action.add_argument('--render', action='store_true')
args = parser.parse_args() 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__': if __name__ == '__main__':
main() main()
+16 -18
View File
@@ -15,7 +15,7 @@ import generator.solo.policy
import generator.solo.realization import generator.solo.realization
import generator.solo.types 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) timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo') region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
choices = generator.scales.selection.select(timeline, region, scales) 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): 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') 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) 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): def realize(plan, previous):
original = plan
for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1): for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1):
context.batch = attempt
try: try:
options = generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context) options = generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context)
if attempt:
context.results[(original, previous)] = options
return options return options
except generator.solo.realization.SearchExhausted as error: except generator.solo.realization.SearchExhausted as error:
if attempt == generator.solo.policy.DEFAULT.replan_attempts or plan.treatment in ('quote', 'exact_quote'): if attempt == generator.solo.policy.DEFAULT.replan_attempts or plan.treatment in ('quote', 'exact_quote'):
raise raise
context.diagnostics.setdefault('replanning', []).append({'phrase': plan.id, 'attempt': attempt + 1, 'reason': str(error), 'adjustment': 'contour reference' if attempt == 0 else 'opening reference'}) context.diagnostics.setdefault('replanning', []).append({'phrase': plan.id, 'attempt': attempt + 1, 'reason': str(error), 'adjustment': 'next diverse complete rhythm and anchor constructions; contracts unchanged'})
# Replan only ordinary development contracts; harmony, anchor pitches,
# required holds, timing, activity and protected quotations stay fixed.
treatment = 'contour_reference' if attempt == 0 else 'opening_reference'
gestures = tuple(dataclasses.replace(g, treatment=treatment) if g.relationship not in ('statement', 'close') else g for g in plan.gestures)
plan = dataclasses.replace(plan, gestures=gestures)
phrases = choose_phrases(plans, realize) 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['policy'] = generator.solo.policy.describe()['fingerprint']
context.diagnostics['musical_identity'] = generator.solo.analysis.identity(analysis, measures, part) 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)) 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 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 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.""" """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): for index, plan in enumerate(plans):
options = realize(plan, previous) options = realize(plan, previous)
if pending is not None and pending not in options:
options = (pending, *options)
ranked, failure = [], None ranked, failure = [], None
for option in options: for option in options:
notes = phrase_notes(option) notes = phrase_notes(option)
last = notes[-1] if notes else previous last = notes[-1] if notes else previous
incoming = transition_cost(result[-1], option) if result else 0 incoming = transition_cost(result[-1], option) if result else 0
if index + 1 == len(plans): if index + 1 == len(plans):
ranked.append((option.score + incoming, option)) ranked.append((option.score + incoming, option, None))
continue continue
try: try:
following = realize(plans[index + 1], option.state or last) 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) length = float(option.plan.end - option.plan.start)
next_length = float(plans[index + 1].end - plans[index + 1].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) 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) for future in following:
ranked.append((cost + incoming, option)) 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: if not ranked:
raise failure or generator.solo.realization.SearchExhausted(f'{plan.id}: no feasible phrase transition') 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) result.append(chosen)
notes = phrase_notes(chosen) notes = phrase_notes(chosen)
if chosen.state: if chosen.state:
+359
View File
@@ -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}
+9 -18
View File
@@ -130,7 +130,7 @@ def anchor_backbones(identifier, start, end, release, register, choices, bar, se
if terminal_start <= opening + fractions.Fraction(1, 2): if terminal_start <= opening + fractions.Fraction(1, 2):
moments = [] moments = []
moments.append((terminal_start, 'terminal')) 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): for index, (time, role) in enumerate(moments):
choice = next(c for c in active if c.span.start <= time < c.span.end) choice = next(c for c in active if c.span.start <= time < c.span.end)
chord_roles = generator.solo.harmonic.roles(choice.span.chord) 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()))] 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) desired = register[0] + (5 if index == 0 else 8 if role == 'terminal' else 11)
following = [] following = []
for cost, anchors in states: for cost, anchors, intention in states:
for pitch in pitches: for pitch in pitches:
jump = abs(pitch.number() - anchors[-1].pitch.number()) if anchors else 0 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) wrong_way = bool(anchors and (pitch.number() - anchors[-1].pitch.number()) * direction < 0)
preferred = target_pitches(choice.span.chord, role == 'terminal') 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) 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))) 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[1][0].pitch.number(), item[1][-1].pitch.number()), generator.solo.policy.DEFAULT.alternatives)) 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(anchors for _, anchors in states) 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, ...]: def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choices, end: fractions.Fraction, part: str, sequences: generator.solo.sequences.Library) -> tuple[generator.solo.types.PhrasePlan, ...]:
boundaries = [fractions.Fraction()] boundaries = [fractions.Fraction()]
@@ -207,23 +208,13 @@ def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choi
if slot < 2 and slot not in inventions: if slot < 2 and slot not in inventions:
inventions[slot] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed, variant=slot) inventions[slot] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed, variant=slot)
motif = head if slot == 2 else inventions[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 phrase_start = start
for phrase_stop in phrase_stops: for phrase_stop in phrase_stops:
phrase_end = boundaries[phrase_stop] phrase_end = boundaries[phrase_stop]
terminal = phrase_end == finish terminal = phrase_end == finish
treatment = 'opening_reference' if motif.origin == 'melody' else 'invented' 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 () 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 phrase_start = phrase_end
index = stop index = stop
return tuple(result) return tuple(result)
+7 -1
View File
@@ -11,7 +11,7 @@ class Policy:
electric_activity: float = 1.375 electric_activity: float = 1.375
active_occupancy: float = 0.75 active_occupancy: float = 0.75
arrival_occupancy: float = 0.9 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 beam_width: int = 64
extensions: int = 32 extensions: int = 32
retrieval: int = 32 retrieval: int = 32
@@ -21,6 +21,12 @@ class Policy:
suspension_preparation: float = 0.5 suspension_preparation: float = 0.5
suspension_resolution: float = 1.0 suspension_resolution: float = 1.0
replan_attempts: int = 2 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. # Preserve the former default root seeds while decoupling them from policy edits.
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1' SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
+410 -100
View File
@@ -3,6 +3,9 @@ from __future__ import annotations
import dataclasses import dataclasses
import fractions import fractions
import functools import functools
import time
import generator.solo.drafts
import generator.fretboard import generator.fretboard
import generator.model import generator.model
@@ -348,121 +351,428 @@ def candidates(cell, plan, choices, sequences, state=None, anchors=(), harmony=N
class SearchContext: class SearchContext:
pools: dict = dataclasses.field(default_factory=dict) pools: dict = dataclasses.field(default_factory=dict)
results: 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': {}}) 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): 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' 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) def phrase_context(plan, incoming, notes):
class BeamState: return generator.solo.drafts.canonical_context(notes, incoming, plan)
score: float
decisions: tuple def evaluation_plan(plan, anchors):
music: generator.solo.types.ContinuationState landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, a.pitch.number(), a.pitch.number(), name=a.role) for a in anchors)
travel: int = 0 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)
fidelity: tuple[float, ...] = ()
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): def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
context = context if context is not None else SearchContext() 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: if key in context.results:
context.diagnostics['cache_hits'] += 1 context.diagnostics['cache_hits'] += 1
return context.results[key] return context.results[key]
started = time.monotonic()
context.diagnostics['searches'] += 1 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) 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) harmony = generator.solo.harmonic.Harmony(choices)
initial = incoming if continuing else generator.solo.types.ContinuationState(plan.passage_id, last=incoming.last, treatments=plan.treatments) plan = legacy_target_plan(plan, choices)
backbones = plan.anchor_options or tuple([()]) if pool_key in context.pools:
# A caller explicitly scheduling quotation owns its rhythm and pitches. Such selected = context.pools[pool_key]
# plans must provide compatible anchors; no automatic rewrite is permitted. context.diagnostics['reused_draft_pools'] = context.diagnostics.get('reused_draft_pools', 0) + 1
states = [BeamState(0, (), dataclasses.replace(initial, backbone=i), initial.travel, initial.fidelity) for i in ([initial.backbone] if continuing else range(len(backbones)))] else:
if plan not in context.pools: constructions = generator.solo.drafts.rhythms(plan, incoming, measures, choices, rhythms, context.batch)
context.pools[plan] = cells(plan, measures, rhythms, choices) complete, failure = [], ''
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) for rhythm in constructions:
last_error = '' paths, error = draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, context)
for cell in context.pools[plan]: failure = error or failure
evaluation = generator.solo.scoring.Evaluation(evaluation_plan, choices, cell.end) context.diagnostics['generated_candidates'] += len(paths)
following, pools = [], {} for notes, cost, contracts in paths:
for state in states: family = note_family(notes)
music = state.music counts = context.diagnostics['candidate_families']
anchors = backbones[music.backbone] counts[family] = counts.get(family, 0) + 1
reference = generator.solo.motifs.reference(plan, cell.gesture, music.motifs) realizations = [notes]
# Concrete results depend on pitch, fingering, motif, anchor progress, 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):
# rhythm history and unresolved treatments. Abstract rhythms alone cache realizations.append((dataclasses.replace(notes[0], continuation=True), *notes[1:]))
# across unrelated incoming lines. for actual in realizations:
pool_key = (music, anchors) draft = make_draft(rhythm, actual, plan, harmony, incoming)
if pool_key not in pools: phrase = checked_phrase(plan, draft, actual, contracts, incoming, harmony, tempo)
pool = candidates(cell, plan, choices, sequences, music, anchors, harmony) if phrase:
context.diagnostics['generated_candidates'] += len(pool) complete.append(dataclasses.replace(phrase, score=phrase.score + 0.1 * cost))
for candidate in pool: if not complete:
family = note_family(candidate.notes) 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)')
counts = context.diagnostics['candidate_families'] context.diagnostics['construction_seconds'] = context.diagnostics.get('construction_seconds', 0) + time.monotonic() - started
counts[family] = counts.get(family, 0) + 1 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)
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) selected = tuple(elaborate(p, incoming, harmony, sequences, tempo, context) for p in selected)
for candidate in pools[pool_key]: context.pools[pool_key] = selected
context.diagnostics['evaluated_extensions'] += 1 input_id = generator.solo.policy.stable_rank(plan.seed, (plan, previous, choices))[:16]
placed = generator.solo.placement.place(candidate.notes, tempo, music.last) pool_id = generator.solo.policy.stable_rank(plan.seed, tuple((p.draft.id, emitted_notes(p)) for p in selected))
if placed is None: context.diagnostics.setdefault('draft_pool_fingerprints', {})[input_id] = pool_id
continue result = []
comfort, notes = placed for phrase in selected:
canonical = generator.solo.types.sounding_notes((*music.notes, *notes)) if context.revise:
completed = cell.end == (plan.passage_end or plan.end) phrase = revise_joins(phrase, incoming, harmony, sequences, tempo, context)
harmonic = harmony.evaluate(canonical, candidate.treatments, until=cell.end, complete=completed) result.append(phrase)
valid, error = generator.solo.harmonic.anchors_valid(anchors, canonical, cell.end, complete=completed, closure=plan.closure) result = tuple(sorted(result, key=lambda p: (p.score, p.draft.id)))
if not harmonic.valid or not valid: for phrase in result:
last_error = harmonic.error or error family = note_family(emitted_notes(phrase))
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:])
counts = context.diagnostics['surviving_families'] counts = context.diagnostics['surviving_families']
counts[family] = counts.get(family, 0) + 1 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 context.results[key] = result
return result return result
+13 -2
View File
@@ -11,13 +11,14 @@ import generator.scales.selection
import generator.scales.timeline import generator.scales.timeline
import generator.solo.types import generator.solo.types
import generator.solo.harmonic import generator.solo.harmonic
import generator.solo.drafts
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str: 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') region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
positioned = tuple(dataclasses.replace(c, span=dataclasses.replace(c.span, start=c.span.start + time_offset, end=c.span.end + time_offset, measure=c.span.measure + measure_offset)) for c in solo.choices) positioned = tuple(dataclasses.replace(c, span=dataclasses.replace(c.span, start=c.span.start + time_offset, end=c.span.end + time_offset, measure=c.span.measure + measure_offset)) for c in solo.choices)
lines = [generator.scales.report.passage(solo.part, region, positioned, scales), f'Phrase composition: {solo.part}', f'Placement reference tempo: {solo.tempo} quarter notes/minute. Runtime tempo changes are not a comfort guarantee.', 'Times below are quarter-note offsets from the start of this solo; pitches are sounding.'] lines = [generator.scales.report.passage(solo.part, region, positioned, scales), f'Phrase composition: {solo.part}', f'Placement reference tempo: {solo.tempo} quarter notes/minute. Runtime tempo changes are not a comfort guarantee.', 'Times below are quarter-note offsets from the start of this solo; pitches are sounding.']
if solo.diagnostics: 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) detail = structure(solo)
for passage in detail['passages']: for passage in detail['passages']:
lines.append(f"Melodic passage {passage['id']}: {passage['start']}-{passage['end']}; phrases {', '.join(passage['phrases'])}") 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 plan = phrase.plan
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources))) lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}') 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: 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}') 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: for landmark in plan.landmarks:
@@ -81,5 +89,8 @@ def structure(solo):
event = generator.solo.harmonic.anchor_note(anchor, notes) 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}) 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 () 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])} 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])}
+54
View File
@@ -33,6 +33,7 @@ class Note:
role: str = 'continuation' role: str = 'continuation'
position: generator.fretboard.TabPosition | None = None position: generator.fretboard.TabPosition | None = None
continuation: bool = False continuation: bool = False
event_id: str = ''
def sounding_notes(notes): def sounding_notes(notes):
"""Collapse explicitly continued attacks, never adjacent repeated pitches.""" """Collapse explicitly continued attacks, never adjacent repeated pitches."""
@@ -130,6 +131,57 @@ class ContinuationState:
backbone: int = 0 backbone: int = 0
fidelity: tuple[float, ...] = () fidelity: tuple[float, ...] = ()
travel: int = 0 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) @dataclasses.dataclass(frozen=True)
class MelodicPassagePlan: class MelodicPassagePlan:
@@ -190,6 +242,8 @@ class Phrase:
score: float = 0 score: float = 0
state: ContinuationState | None = None state: ContinuationState | None = None
anchors: tuple[Anchor, ...] = () anchors: tuple[Anchor, ...] = ()
draft: MelodicDraft | None = None
edits: tuple[DraftEdit, ...] = ()
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Solo: class Solo:
+2
View File
@@ -34,6 +34,8 @@ class ArrangementPickupTests(unittest.TestCase):
generator.model.validate_document(result) generator.model.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 176) 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) self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 704)
timelines.append([m['duration'] for m in measures]) timelines.append([m['duration'] for m in measures])
if path.stem == 'autumn_leaves' and part in ('vocal', 'electric'): if path.stem == 'autumn_leaves' and part in ('vocal', 'electric'):
+5 -4
View File
@@ -124,14 +124,15 @@ class ElectricVoicingTests(unittest.TestCase):
self.assertEqual(measures, original) self.assertEqual(measures, original)
if section.role != 'accompaniment': if section.role != 'accompaniment':
continue 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']): 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']]) 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']: 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 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.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'])) 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'}) self.assertEqual(checked, {'4', '12', '22', '24'})
+7
View File
@@ -33,6 +33,13 @@ def notes(document, measure=0):
return document['staves'][0]['measures'][measure]['voices'][0]['notes'] return document['staves'][0]['measures'][measure]['voices'][0]['notes']
class MusicXMLTests(unittest.TestCase): 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): def test_obsolete_style_hints_are_rejected(self):
for value in ('Medium Swing', 'Med.', 'Bossa Nova', 'Unrecognized style', ''): 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'"): with self.subTest(value=value), self.assertRaisesRegex(generator.musicxml.MusicXMLError, "fixture.xml: unsupported Vamp property 'vamp:style'"):
+1 -1
View File
@@ -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()}) 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)) 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)) 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) generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}) 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>'))) source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
+1 -1
View File
@@ -154,7 +154,7 @@ class SoloTests(unittest.TestCase):
self.assertEqual(result.phrases[-1].plan.end, fractions.Fraction(43, 2)) self.assertEqual(result.phrases[-1].plan.end, fractions.Fraction(43, 2))
def test_search_exhaustion_does_not_claim_impossibility(self): def test_search_exhaustion_does_not_claim_impossibility(self):
source = song() 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) compose(source)
def test_time_aware_placement_does_not_penalize_absolute_frets(self): def test_time_aware_placement_does_not_penalize_absolute_frets(self):
p = generator.solo.types.Pitch('c', fractions.Fraction(), 4) p = generator.solo.types.Pitch('c', fractions.Fraction(), 4)
+1 -1
View File
@@ -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)) 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) notes = generator.solo.composer.phrase_notes(phrase)
self.assertGreaterEqual(sum(n.start < 8 for n in notes), 12) 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.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) self.assertEqual(notes[-1].end, 16)
def test_exact_quote_cannot_silently_fragment_at_no_harmony(self): def test_exact_quote_cannot_silently_fragment_at_no_harmony(self):
+235
View File
@@ -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)
+7 -4
View File
@@ -9,6 +9,7 @@ import generator.harmony
import generator.model import generator.model
import generator.musicxml import generator.musicxml
import generator.rhythm import generator.rhythm
import generator.score
import tests.rhythm_fixtures import tests.rhythm_fixtures
import tests.test_musicxml import tests.test_musicxml
import tests.test_parts 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()) result = generator.arrange.generate.arrange_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(result) generator.model.validate_document(result)
section, checked = '', set() section, checked = '', set()
source_index = generator.score.body_start_measure_index(source['staves'][0]['measures'])
for measure in result['staves'][0]['measures']: for measure in result['staves'][0]['measures']:
for voice in measure['voices']: for voice in measure['voices']:
for note in voice['notes']: for note in voice['notes']:
@@ -198,19 +200,20 @@ class VoicingTests(unittest.TestCase):
section = measure.get('section_label') or section section = measure.get('section_label') or section
if section != 'Guitar Melody': if section != 'Guitar Melody':
continue 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']: for note in measure['voices'][0]['notes']:
if not note['pitches']: if not note['pitches']:
continue continue
onset = generator.model.fraction(note['onset']) 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'])) 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) 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 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'])) frets = tuple(p['tablature']['fret'] for p in sorted(note['pitches'], key=lambda p: -p['tablature']['string']))
self.assertEqual(frets, expected) self.assertEqual(frets, expected)
self.assertEqual(generator.model.pitch_number(note['pitches'][0]), max(generator.model.pitch_number(p) for p in note['pitches'])) 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(checked, {'4', '12', '22', '24'})
self.assertEqual(source, before) self.assertEqual(source, before)