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