Compare commits

...

2 Commits

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