This commit is contained in:
khodowany
2026-10-02 00:35:31 -05:00
parent f2f371b951
commit a0bd19b3ba
25 changed files with 1421 additions and 586 deletions
+11 -9
View File
@@ -95,23 +95,25 @@ Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live
`data/songs` supplies the authored music. `data/rhythms/<meter>/<series>/` supplies the accompaniment rhythm library for 2/4, 3/4, 4/4, and 5/4. All three series are active: the original quarter/eighth patterns plus series `3` with half, dotted-half, and whole notes/rests. Rhythm snippets are build inputs, not separate catalog entries or runtime files. Generation loads the library once and shares it across parts. `data/songs` supplies the authored music. `data/rhythms/<meter>/<series>/` supplies the accompaniment rhythm library for 2/4, 3/4, 4/4, and 5/4. All three series are active: the original quarter/eighth patterns plus series `3` with half, dotted-half, and whole notes/rests. Rhythm snippets are build inputs, not separate catalog entries or runtime files. Generation loads the library once and shares it across parts.
`data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. The Python build uses these collections, the song melody, the accepted paired pitch/rhythm references in `data/lead_phrases`, `data/sequences`, and `data/rhythms` to compose acoustic and electric solos with transformed motifs, connected melodic lines, varying activity, sustained arrivals, and planned space. A passage plan groups roughly four to eight bars, connects opening, intermediate and terminal chord-tone anchors, and carries realized motif identity across smaller search cells and phrase boundaries. A bounded search considers the next phrase before committing. Exact quotation is available but is no longer the default. The other guitar provides solo-aware accompaniment. Scale changes appear above the solo notation and TAB; detailed build-only phrase reports live in `generated_assets/analysis/`. No solo assignments are stored in song XML. Invalid patterns or a harmony without an exact permitted scale fail before replacing the previous output. `data/scales` supplies the 21 C-centered modes of major, melodic minor, and harmonic minor. Build-time Python uses each song's actual melody, its mapped harmony, `data/sequences`, and `data/rhythms` to compose recognizable acoustic and electric variations. The standalone `data/lead_phrases` material is an audition control, not a production seed. No solo assignments are stored in song XML, and the runtime format is unchanged.
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. Source analysis retains complete sounding notes, rests, pickups and cross-bar holds. Production uses **thematic recomposition**: a short rhythm, contour, opening, or characteristic interval from the head initializes a new solo theme. Later phrases develop that realization through harmonic adaptation, extension, answers, or optional brief callbacks. Each instrument establishes a distinct related theme instead of restarting from the corresponding head phrase at every source boundary. Close paraphrase remains available through `composer.compose(..., mode='paraphrase')` for comparison.
Ordinary solos now start from complete paired lead ideas. Up to twelve rhythm/interval identities and two harmonic realizations per identity produce complete four-to-eight-bar candidates. The candidate contains a statement, a recognizable return with the same timing, development, and a real ending. Four-bar passages remain common; a longer thought uses the existing extended-phrase representation. Each small pitch search compares complete timed figures from a fixed pitch origin, so correcting one note does not shift the rest of the idea. The return is checked against the statement actually heard, including every identifying interval and duration. Later passages can recall that realized identity or deliberately contrast. Head-based treatments and exact quotation remain available through their explicit contracts. Treatment-specific contracts preserve the selected features only. Whole-source deviation applies to close paraphrase, including sounding durations; it does not penalize recomposition for changing unrelated notes. Before optional runs, realized pitch/time overlap and interval/rhythm fragments reject substantial near-copies (initial settings: 80% agreement across a two-bar window with at least four source attacks). Octave shifts, reattacks and extra attacks cannot dilute the duration comparison. Additional distance earns no bonus. The original source reference and the solo's realized theme remain separate in state and reports.
Ordinary intermediate anchors are selected from the resulting melody rather than inserted over its rhythm. All sustained sounds still receive chord-by-chord harmonic validation. Complete melody alternatives compete before elaboration, and their unornamented score controls selection: a run cannot rescue a weak theme. Both guitars use a broad activity range of 4–6 attacks per four beats across the passage, with quiet bars and busier runs allowed. Active occupancy between 85% and 100% has no penalty; deliberate breaths and missing harmony remain space. Activity never automatically diminishes or broadens an identifying rhythm. These are audition settings, not a musical-quality claim. Scale permission and chord function remain distinct. Every generated sound must belong to all mapped collections it overlaps. Added chromatic substitutions and their validation exception have been removed; mapped accidentals such as C-sharp in D harmonic minor remain valid. Basic chord members respect suspensions, alterations and omissions; slash-bass requirements do not automatically become melodic targets. Ordinary sounds lasting at least two quarter-note beats must remain chord members across every overlapping chord, unless an explicit valid color or prepared suspension applies. Tied notation is merged before evaluation. Terminal anchors must actually complete, and protected quotations retain their contracts.
Selected figures, their recognizable returns, quotations, and committed arrivals survive elaboration. `data/sequences` supplies complete optional approaches to the tune's destinations using exact octave-aware scale indices. A route retains its timing, pitches, and endpoint across collection changes; an invalid route is rejected as a whole. The existing bounded elaborator and guitar placement remain in use. The plain lead, diatonic elaboration, and final chromatic version share the same underlying melody. The runtime representation, constant playback treatment, frets 1–12, returning vocal pickup, and arrangement-wide measure numbering are unchanged. Rhythm snippets and complete interval routes realize one- or two-bar edits jointly with chord-tone destinations. Routes retain their entry, complete timing, pitch path and arrival across barlines and collection changes. Later candidates receive the incoming note, position and realized theme before pitch generation. Run entries may extend an existing note without reattack. Intermediate arrivals continue the thought; the final destination has a closed-ending contract. Missing harmony creates explicit silence while preserving thematic memory. Short fragments around missing harmony are explicitly reduced and do not claim full motif fidelity.
Light chromaticism is an explicit exception to scale permission, not a new collection. An unprotected offbeat eighth note may approach the next attacked chord member by one semitone, without a gap. It cannot span a harmonic boundary itself, but its resolution may enter the next chord. The actual pitch, event identity, duration, resolution, and deadline must all validate. No chromatic hold, unresolved approach, missing-harmony exception, or change to a quotation is permitted. The budget is at most four per eight bars, spaced at least two bars apart and at most one per route; unsuitable sites are left diatonic. Approach spelling prefers ordinary accidentals over avoidable double accidentals. The existing suspension/color contracts remain separate. The plain recomposed theme is exported independently of optional runs. Activity retains a soft 4–6 attacks-per-four-beats preference; occupancy remains diagnostic. No accents, dynamics, swing or other performance-expression changes are applied.
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. Search retains eight phrase alternatives and an eight-state chorus frontier, with two intermediate continuations per established theme. Up to 64 acoustic/electric pairs compete; equivalent themes after transposition are excluded while individual musical costs and pair similarity remain separate. Concrete caches include incoming state, harmony, timing and vocabulary. One bounded replan broadens eligible start and destination pitches. Exhaustion identifies the failed window without invoking a generic composer. Stable ordering and final tie-breaking preserve determinism.
The earlier phrase-draft experiments remain available for investigating the legacy motif/quotation path. Use the lead-melody comparison below for the current production constructor. Phrase register alternatives cover the available F2–E5 sounding range. Placement uses all six strings, frets 1–12 without open notes, with a four-fret hand-position model and alternative endpoint fingerings. Movement costs use available time; they do not claim a maximum playable speed. Equal-cost tie-breaking has no first-string preference. Chords elsewhere retain their existing 1–12 anchor policy. The other guitarist is recomposed around the selected foreground, and the returning vocal pickup remains protected.
For lead-melody comparisons, preserve the old generator with `python -m generator.solo.audition --output build/solo_lead_melody_auditions --capture baseline`, then use `python -m generator.solo.lead_audition --capture` and `--render`. Open `build/solo_lead_melody_auditions/index.html` to compare baseline, plain lead, diatonic runs, and chromatic approaches with fixed backing or alone. The final version also has responsive accompaniment. Intermediate exports follow the selected production branches, not independent searches. Literal reference controls verify exact sounding notes through validation, guitar placement, notation, and playback. Reports identify the selected idea, return correspondence, complete routes, chromatic resolutions, harmonic exposure, activity, and occupancy. Listen for a tune whose identity survives its ornamentation; metrics alone do not establish musical improvement. The original temporary archive lives in ignored `build/lead_melody_reference.zip`; builds use the tracked structured reference and never execute the archive's script. Build reports in `generated_assets/analysis/` include selected source features, recurring solo themes, development lineage, anchors, complete routes, plain-versus-head and elaborated-versus-head comparisons, and candidate diagnostics. Audition reports additionally show string use weighted by sounding duration and distinguish forced first-string notes from optional placements. These measurements establish technical behavior, not musical quality.
For comparisons, preserve the previous `elaborated/snapshot.json` under `build/solo_melody_auditions/paraphrase_baseline/`, then run `./modulus.py python -m generator.solo.lead_audition --capture` and `--render`. Open `build/solo_melody_auditions/index.html` for the original source, previous close paraphrase, new theme and theme with runs, alone and with fixed backing. Full final mixes also use responsive accompaniment. Source controls preserve the imported melody and may expose original harmonic tensions; production variations must pass strict validation. The temporary reference archive remains ignored at `build/lead_melody_reference.zip`.
To inspect generated assets without building the full package, run: To inspect generated assets without building the full package, run:
+1 -3
View File
@@ -22,7 +22,6 @@ import generator.scales.library
import generator.score import generator.score
import generator.summary import generator.summary
import generator.solo.sequences import generator.solo.sequences
import generator.solo.references
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt") RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
@@ -76,14 +75,13 @@ def generate_assets(args: argparse.Namespace) -> None:
scales = generator.scales.library.load_library(data_dir / 'scales') scales = generator.scales.library.load_library(data_dir / 'scales')
report('loading solo interval sequences') report('loading solo interval sequences')
sequences = generator.solo.sequences.load_library(data_dir / 'sequences') sequences = generator.solo.sequences.load_library(data_dir / 'sequences')
references = generator.solo.references.load_library(data_dir / 'lead_phrases')
output_dir.parent.mkdir(parents=True, exist_ok=True) output_dir.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary: with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary:
staged = pathlib.Path(temporary) / 'assets' staged = pathlib.Path(temporary) / 'assets'
staged.mkdir(parents=True, exist_ok=True) staged.mkdir(parents=True, exist_ok=True)
copy_runtime_fonts(static_assets_dir, staged) copy_runtime_fonts(static_assets_dir, staged)
report('generating performance parts') report('generating performance parts')
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences, references=references) performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences)
generator.score.validate_generated_tree(staged) generator.score.validate_generated_tree(staged)
if args.check: if args.check:
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs') generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
+57 -5
View File
@@ -16,6 +16,8 @@ class Analysis:
boundaries: tuple[fractions.Fraction, ...] boundaries: tuple[fractions.Fraction, ...]
tempo: fractions.Fraction tempo: fractions.Fraction
melody: tuple[generator.solo.types.Note, ...] melody: tuple[generator.solo.types.Note, ...]
body_offset: fractions.Fraction = fractions.Fraction()
source_phrases: tuple[generator.solo.types.SourcePhrase, ...] = ()
def identity(analysis, measures, part): def identity(analysis, measures, part):
anchor = analysis.melody[0].pitch.number() if analysis.melody else 0 anchor = analysis.melody[0].pitch.number() if analysis.melody else 0
@@ -63,16 +65,14 @@ def analyze(document: dict) -> Analysis:
candidates = [] candidates = []
for index, measure in enumerate(measures): for index, measure in enumerate(measures):
start = boundaries[index] start = boundaries[index]
# One- and two-bar ideas retain leading silence and the complete held ending. # Legacy motif consumers also receive complete longer source material.
for count in (1, 2): for count in (1, 2, 4, 6, 8):
if index + count >= len(boundaries): if index + count >= len(boundaries):
continue continue
end = boundaries[index + count] end = boundaries[index + count]
notes = tuple(n for n in melody if start <= n.start < end) notes = tuple(n for n in melody if start <= n.start < end)
if not notes or any(n.end > end for n in notes) or any(a.end > b.start for a, b in zip(notes, notes[1:])): if not notes or any(n.end > end for n in notes) or any(a.end > b.start for a, b in zip(notes, notes[1:])):
continue continue
if len(notes) > 8:
continue
normalized = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in notes) normalized = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in notes)
identity = repr(tuple((n.start, n.end, n.pitch) for n in normalized)).encode('ascii') identity = repr(tuple((n.start, n.end, n.pitch) for n in normalized)).encode('ascii')
identifier = 'head_' + hashlib.sha256(identity).hexdigest()[:12] identifier = 'head_' + hashlib.sha256(identity).hexdigest()[:12]
@@ -82,4 +82,56 @@ def analyze(document: dict) -> Analysis:
# Preserve the opening idea, then favor a contrasting rhythm/contour with space. # Preserve the opening idea, then favor a contrasting rhythm/contour with space.
ordered = list(unique.values()) ordered = list(unique.values())
ordered.sort(key=lambda m: (next(i for i, n in enumerate(melody) if n.sources[0] == m.notes[0].sources[0]), m.duration)) ordered.sort(key=lambda m: (next(i for i, n in enumerate(melody) if n.sources[0] == m.notes[0].sources[0]), m.duration))
return Analysis(tuple(ordered), tuple(boundaries), tempo, melody) offset = boundaries[generator.score.body_start_measure_index(measures)]
phrases = source_phrases(melody, offset, boundaries[-1] - offset,
generator.model.fraction(measures[-1]['duration']))
return Analysis(tuple(ordered), tuple(boundaries), tempo, melody, offset, phrases)
def source_phrases(melody, offset, end, bar):
"""Partition at musical releases, without slicing a sounding event at bars."""
notes = tuple(dataclasses.replace(n, start=n.start-offset, end=n.end-offset,
event_id=n.sources[0]) for n in melody if n.end > offset)
if any(a.end > b.start for a, b in zip(notes, notes[1:])):
raise ValueError('solo source requires a monophonic melody')
candidates = sorted({fractions.Fraction(), end, *(n.end for n in notes if 0 < n.end <= end),
*(n.start for n in notes if 0 < n.start < end)})
candidates = [t for t in candidates if not any(n.start < t < n.end for n in notes)]
if not candidates or candidates[0] != 0:
candidates.insert(0, fractions.Fraction())
best = {end: (0., ())}
for start in reversed(candidates[:-1]):
options = []
for finish in candidates:
duration = finish-start
if finish not in best or duration <= 0 or duration > 8*bar or duration < 2*bar and finish != end:
continue
prior = next((n for n in reversed(notes) if n.end <= finish), None)
after = next((n for n in notes if n.start >= finish), None)
gap = float(max(0, after.start-prior.end)) if prior and after else 0.
held = float(prior.end-prior.start) if prior else 0.
cost = abs(float(duration/bar)-4)/4 + .7*(held < 2) - .3*min(2,gap)
options.append((cost+best[finish][0], (finish,*best[finish][1])))
if options:
best[start] = min(options)
if 0 not in best:
# A single extended held event is one musical unit, regardless of length.
stops = (end,)
else:
stops = best[0][1]
result, start = [], fractions.Fraction()
for finish in stops:
actual = tuple(n for n in notes if n.start < finish and n.end > start)
cursor, rests = start, []
for n in actual:
if cursor < n.start:
rests.append((cursor,n.start))
cursor = n.end
if cursor < finish:
rests.append((cursor,finish))
origin = actual[0].pitch.number() if actual else 0
shape = tuple((n.start-start,n.end-start,n.pitch.number()-origin) for n in actual)
family = hashlib.sha256(repr(shape).encode()).hexdigest()[:12]
pickup = tuple(dataclasses.replace(n,start=n.start-offset,end=n.end-offset,event_id=n.sources[0]) for n in melody if n.end<=offset) if start==0 else ()
result.append(generator.solo.types.SourcePhrase(f'source_{start}_{finish}',start,finish,actual,tuple(rests),family,pickup))
start = finish
return tuple(result)
+8 -2
View File
@@ -93,7 +93,8 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
head_first, head_count, _, _ = sections[2] head_first, head_count, _, _ = sections[2]
head = parts['electric']['staves'][0]['measures'][head_first:head_first + head_count] head = parts['electric']['staves'][0]['measures'][head_first:head_first + head_count]
reports = [] reports = []
for section_index, part, other in ((3, 'acoustic', 'electric'), (4, 'electric', 'acoustic')): requests = {}
for section_index, part in ((3, 'acoustic'), (4, 'electric')):
first, count, offset, length = sections[section_index] first, count, offset, length = sections[section_index]
initial = generator.accompaniment.carried_chords(parts[part]['staves'][0]['measures'])[first] initial = generator.accompaniment.carried_chords(parts[part]['staves'][0]['measures'])[first]
template = copy.deepcopy(parts[part]['staves'][0]['measures'][first:first + count]) template = copy.deepcopy(parts[part]['staves'][0]['measures'][first:first + count])
@@ -103,7 +104,12 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']] sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']]
if sounding: if sounding:
cutoff -= generator.model.fraction(pickup['duration']) - min(sounding) cutoff -= generator.model.fraction(pickup['duration']) - min(sounding)
solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial, revise=revise, references=references, stage=stage, context=(contexts or {}).get(part)) requests[part] = (template, cutoff, initial)
selected = generator.solo.composer.compose_pair(analysis, requests, scales=scales, sequences=sequences, rhythms=rhythms, contexts=contexts, stage=stage)
for solo, section_index, other in zip(selected, (3, 4), ('electric', 'acoustic')):
part = solo.part
first, count, offset, length = sections[section_index]
template, cutoff, initial = requests[part]
if solos is not None: if solos is not None:
solos.append(solo) solos.append(solo)
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part) foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
+5 -5
View File
@@ -121,10 +121,10 @@ def capture(root, stage, libraries=None, contexts=None):
for phrase in solo.phrases: for phrase in solo.phrases:
if not phrase.draft: if not phrase.draft:
continue continue
begin = boundaries.index(phrase.plan.start) begin = max(i for i,boundary in enumerate(boundaries) if boundary <= phrase.plan.start)
stop = next(i for i, boundary in enumerate(boundaries) if boundary >= phrase.plan.end) stop = next(i for i, boundary in enumerate(boundaries) if boundary >= phrase.plan.end)
view = excerpt(parts[solo.part], first + begin, stop - begin) 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)) local = tuple(dataclasses.replace(n, start=n.start - boundaries[begin], end=n.end - boundaries[begin], continuation=False if i == 0 else n.continuation) for i, n in enumerate(phrase.draft.notes))
placed = generator.solo.placement.place(local, solo.tempo) placed = generator.solo.placement.place(local, solo.tempo)
if placed is None: if placed is None:
raise ValueError(f'{phrase.plan.id}: draft cannot be placed for independent audition') raise ValueError(f'{phrase.plan.id}: draft cannot be placed for independent audition')
@@ -143,16 +143,16 @@ def capture(root, stage, libraries=None, contexts=None):
if placed is None: if placed is None:
raise ValueError(stage_name + ': selected theme cannot be placed') raise ValueError(stage_name + ': selected theme cannot be placed')
sounded = generator.solo.types.sounding_notes(placed[1]) sounded = generator.solo.types.sounding_notes(placed[1])
contracts = tuple(dict.fromkeys(t for p in solo.phrases for t in p.state.treatments if t.kind != 'chromatic_approach')) contracts = tuple(dict.fromkeys(t for p in solo.phrases for t in p.state.treatments))
checked = generator.solo.harmonic.Harmony(solo.choices).evaluate(sounded, contracts, until=solo.end, complete=True) checked = generator.solo.harmonic.Harmony(solo.choices).evaluate(sounded, contracts, until=solo.end, complete=True)
if not checked.valid: if not checked.valid and stage_name != 'initial':
raise ValueError(stage_name + ': ' + checked.error) raise ValueError(stage_name + ': ' + checked.error)
view = excerpt(parts[solo.part], first, count) view = excerpt(parts[solo.part], first, count)
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], stage_name + '_' + solo.part) foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], stage_name + '_' + solo.part)
generator.solo.notation.replace_region(view, 0, foreground) generator.solo.notation.replace_region(view, 0, foreground)
generator.model.validate_document(view) generator.model.validate_document(view)
generator.fretboard.validate_document(view) generator.fretboard.validate_document(view)
stages[stage_name] = {'document': view, 'attacks': len(sounded), 'occupancy': float(sum(n.end - n.start for n in sounded) / solo.end), 'harmonic_exposure': checked.exposure} stages[stage_name] = {'document': view, 'attacks': len(sounded), 'occupancy': float(sum(n.end - n.start for n in sounded) / solo.end), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error}
song['solos'][solo.part]['lead_stages'] = stages song['solos'][solo.part]['lead_stages'] = stages
print(path.stem, solo.part, json.dumps({k: v for k, v in song['solos'][solo.part]['metrics'].items() if k != 'search'}), flush=True) print(path.stem, solo.part, json.dumps({k: v for k, v in song['solos'][solo.part]['metrics'].items() if k != 'search'}), flush=True)
result['songs'][path.stem] = song result['songs'][path.stem] = song
+35 -31
View File
@@ -15,45 +15,49 @@ import generator.solo.policy
import generator.solo.realization import generator.solo.realization
import generator.solo.types import generator.solo.types
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated'): def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated', mode='thematic'):
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial) timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo') region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
choices = generator.scales.selection.select(timeline, region, scales) choices = generator.scales.selection.select(timeline, region, scales)
for choice in choices: for choice in choices:
if choice.span.chord and choice.scale is None: if choice.span.chord and choice.scale is None:
raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog') raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog')
plans = generator.solo.planning.plans(analysis, measures, choices, end, part, sequences, lead=bool(references)) from generator.solo import leads
retained = tuple((plan.start + n.start, plan.start + n.end, {n.pitch.number() % 12}) for plan in plans if plan.treatment in ('quote', 'exact_quote') for n in plan.motif.notes)
if retained and not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
choices = generator.scales.selection.select(timeline, region, scales, retained=retained)
if not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
raise ValueError(f'{part} solo: protected quotation has no exact compatible scale collection')
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 = context if context is not None else generator.solo.realization.SearchContext() context = context if context is not None else generator.solo.realization.SearchContext()
context.revise = revise options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode)
context.references = references result = options[0][1]
context.stage = stage
def realize(plan, previous):
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)
return options
except generator.solo.realization.SearchExhausted as error:
if attempt == generator.solo.policy.DEFAULT.replan_attempts or plan.treatment in ('quote', 'exact_quote', 'lead'):
raise
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, 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))
passages = tuple(generator.solo.types.MelodicPassagePlan(identifier, min(p.start for p in plans if p.passage_id == identifier), max(p.end for p in plans if p.passage_id == identifier), tuple(p.id for p in plans if p.passage_id == identifier), next(p.seed for p in plans if p.passage_id == identifier)) for identifier in passage_ids)
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end, context.diagnostics, passages)
validate(result) validate(result)
return result return result
def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic'):
"""Select two recognizable interpretations together, before arranging backing."""
from generator.solo import leads
pools = {}
for part, (measures,end,initial) in requests.items():
timeline = generator.scales.timeline.read({'measures': measures},initial=initial)
choices = generator.scales.selection.select(timeline,generator.scales.timeline.Solo(0,end,part),scales)
for c in choices:
if c.span.chord and c.scale is None:
raise ValueError(f'{part} solo, source measure {c.span.source_measure}: No exact match within the permitted catalog')
context = (contexts or {}).get(part) or generator.solo.realization.SearchContext()
pools[part] = leads.solo_options(analysis,measures,part,end,choices,sequences,rhythms,context,stage,mode)
def theme_key(solo):
home=next((p.state.home for p in solo.phrases if p.state and p.state.home),None)
if not home or not home.notes:return ()
return tuple((n.start-home.notes[0].start,n.end-n.start,n.pitch.number()-home.notes[0].pitch.number()) for n in home.notes)
pairs = [(a_cost+e_cost+2*leads.similarity(a,e),a,e) for a_cost,a in pools['acoustic'] for e_cost,e in pools['electric']
if mode!='thematic' or not theme_key(a) or theme_key(a)!=theme_key(e)]
if not pairs:
raise generator.solo.realization.SearchExhausted('Distinct acoustic/electric themes not found within the bounded pair search')
_,acoustic,electric = min(pairs,key=lambda v:(v[0],generator.solo.policy.stable_rank('solo-pair',(v[1].notes(),v[2].notes()))))
overlap = leads.similarity(acoustic,electric)
for solo in (acoustic,electric):
solo.diagnostics.update(pair_similarity=overlap,pairs_compared=len(pairs),solo_alternatives=len(pools[solo.part]))
validate(solo)
return acoustic,electric
def phrase_notes(phrase): def phrase_notes(phrase):
notes = tuple(n for d in phrase.decisions for n in d.notes) notes = tuple(n for d in phrase.decisions for n in d.notes)
if notes and notes[0].continuation: if notes and notes[0].continuation:
@@ -116,8 +120,8 @@ def validate(solo):
for note in solo.notes(): for note in solo.notes():
if note.start < 0 or note.end > solo.end or note.end <= note.start or previous and previous.end > note.start: if note.start < 0 or note.end > solo.end or note.end <= note.start or previous and previous.end > note.start:
raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}') raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
# Harmony.evaluate below owns scale permission and the narrow, verified if note.pitch.number() % 12 not in generator.solo.types.permitted(solo.choices, note.start, note.end):
# chromatic exception; no separate permissive final-validation path. raise ValueError(f'{solo.part} solo: note outside mapped collection at {note.start}')
if note.position not in generator.fretboard.single_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}') raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
previous = note previous = note
+1 -1
View File
@@ -385,7 +385,7 @@ def relationship_features(draft, notes):
actual = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(group, group[1:])) 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)) direction.extend(a != b for a, b in zip(actual, transform.directions))
reference = draft.reference_motif if transform.operation in ('statement', 'contrast') else draft.home reference = draft.reference_motif if transform.operation in ('statement', 'contrast') else draft.home
if reference and not draft.lead_identity and transform.operation not in ('close', 'fragment', 'broaden_ending'): if reference and not draft.lead_identity and not draft.source_phrase and transform.operation not in ('close', 'fragment', 'broaden_ending'):
development.append(generator.solo.references.relationship(group, reference)) development.append(generator.solo.references.relationship(group, reference))
joins = [] joins = []
for time in draft.joins: for time in draft.joins:
+3 -29
View File
@@ -99,32 +99,6 @@ class Harmony:
for treatment in treatments: for treatment in treatments:
if treatment.start >= until: if treatment.start >= until:
continue continue
if treatment.kind == 'chromatic_approach':
approach = next((n for n in notes if n.event_id == treatment.event), None)
if (approach is None or approach.role == 'quote' or approach.pitch.number() != treatment.pitch
or approach.start != treatment.start or approach.end != treatment.end
or approach.end - approach.start != fractions.Fraction(1, 2)
or approach.start % 1 != fractions.Fraction(1, 2)
or treatment.resolution is None or abs(treatment.pitch - treatment.resolution) != 1
or treatment.deadline != approach.end or len(self.segments(approach)) != 1):
return Result(False, 0, error=f'{treatment.id}: invalid chromatic approach event or timing')
span = self.at(approach.start)
if not span or not span.scale or treatment.pitch % 12 in span.scale.pitches:
return Result(False, 0, error=f'{treatment.id}: approach requires mapped harmony and an out-of-collection pitch')
resolution = next((n for n in notes if n.start == approach.end), None)
if resolution:
destination = self.at(resolution.start)
if (resolution.event_id != treatment.resolution_event or resolution.continuation
or resolution.pitch.number() != treatment.resolution or not destination or not destination.scale
or treatment.resolution % 12 not in destination.roles.basic
or treatment.resolution % 12 not in generator.solo.types.permitted(self.choices, resolution.start, resolution.end)):
return Result(False, 0, error=f'{treatment.id}: chromatic approach has no immediate chord-tone resolution')
elif complete or until > approach.end:
return Result(False, 0, error=f'{treatment.id}: unresolved chromatic approach')
else:
pending.append(treatment)
valid_contracts.append(treatment)
continue
if treatment.kind == 'color': if treatment.kind == 'color':
spans = [s for s in self.spans if s.start < treatment.end and s.end > treatment.start] spans = [s for s in self.spans if s.start < treatment.end and s.end > treatment.start]
if not spans or any(treatment.pitch % 12 not in s.roles.basic | s.roles.specified for s in spans): if not spans or any(treatment.pitch % 12 not in s.roles.basic | s.roles.specified for s in spans):
@@ -155,8 +129,8 @@ class Harmony:
sounding += float(duration) sounding += float(duration)
covered = note.start covered = note.start
for span, start, end in self.segments(note): for span, start, end in self.segments(note):
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end and (t.kind != 'chromatic_approach' or t.event == note.event_id)), None) contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None)
if start > covered or not span.scale or number % 12 not in span.scale.pitches and not (contract and contract.kind == 'chromatic_approach'): if start > covered or not span.scale or number % 12 not in span.scale.pitches:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony') return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
covered = end covered = end
function = self.function(number, span) function = self.function(number, span)
@@ -176,7 +150,7 @@ class Harmony:
for index, note in enumerate(notes): for index, note in enumerate(notes):
for span, start, end in self.segments(note): for span, start, end in self.segments(note):
contracts = [t for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end] contracts = [t for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': 'chromatic_approach' if any(t.kind == 'chromatic_approach' for t in contracts) else self.connected_motion(notes, index), 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]}) result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]})
return result return result
def anchor_note(anchor, notes, *, until=None): def anchor_note(anchor, notes, *, until=None):
+8 -7
View File
@@ -79,12 +79,12 @@ def controls(song, libraries):
return result return result
def render(root): def render(root):
baseline = json.loads((root / 'baseline/snapshot.json').read_text()) baseline = json.loads((root / 'paraphrase_baseline/snapshot.json').read_text())
current = json.loads((root / 'elaborated/snapshot.json').read_text()) current = json.loads((root / 'elaborated/snapshot.json').read_text())
cache, clips = {}, [] cache, clips = {}, []
gain = 0.4 # Shared audition gain; every mixed clip is checked for clipping. gain = 0.4 # Shared audition gain; every mixed clip is checked for clipping.
manifest = {'implementation': current['implementation'], 'baseline_implementation': baseline['implementation'], 'policy': current['policy'], 'shared_gain': gain, 'clips': clips, 'stage_semantics': 'Initial is the selected plain tune; refined adds complete diatonic runs; elaborated adds validated chromatic approaches. All stages share the same underlying selected tune.'} manifest = {'implementation': current['implementation'], 'baseline_implementation': baseline['implementation'], 'policy': current['policy'], 'shared_gain': gain, 'clips': clips, 'stage_semantics': 'Source is the original head, close paraphrase is the preserved previous plain theme, refined is the newly recomposed theme, and elaborated includes optional in-scale runs. The selected production branch supplies both new stages.'}
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Lead melody comparisons</title><style>body{font:16px system-ui;max-width:1500px;margin:2rem auto;padding:1rem}table{border-collapse:collapse}td,th{padding:.6rem;border-bottom:1px solid #ccc;text-align:left}audio{width:240px}small{display:block}</style><h1>Lead melody comparisons</h1><p>Compare the plain tune, its diatonic runs, and its chromatic approaches. All fixed mixes use the original backing, identical tempo and one shared gain. Solo-only clips remove the backing. Initial/refined exports follow the selected production branches; they are not independent searches.</p><p>These metrics verify behavior, not musical quality. Listen for a recognizable tune, prepared destinations, and runs that belong to the tune. <a href="manifest.json">Diagnostics manifest</a>.</p>'] body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Lead melody comparisons</title><style>body{font:16px system-ui;max-width:1500px;margin:2rem auto;padding:1rem}table{border-collapse:collapse}td,th{padding:.6rem;border-bottom:1px solid #ccc;text-align:left}audio{width:240px}small{display:block}</style><h1>Lead melody comparisons</h1><p>Compare the original head, the previous close paraphrase, the new solo theme, and its optional in-scale runs. Source controls may contain original harmonic tensions that production explicitly adapts. All fixed mixes use the original backing, identical tempo and one shared gain. Solo-only clips remove the backing. Initial/refined exports follow the selected production branches; they are not independent searches.</p><p>These metrics verify behavior, not musical quality. Listen for a recognizable tune, prepared destinations, and runs that belong to the tune. <a href="manifest.json">Diagnostics manifest</a>.</p>']
def clip(prefix, foreground, tempo, backing=()): def clip(prefix, foreground, tempo, backing=()):
path = root / (prefix + '.wav') path = root / (prefix + '.wav')
@@ -103,13 +103,13 @@ def render(root):
raise ValueError(name + ': source changed during comparison') raise ValueError(name + ': source changed during comparison')
for part, solo in song['solos'].items(): for part, solo in song['solos'].items():
other = 'electric' if part == 'acoustic' else 'acoustic' other = 'electric' if part == 'acoustic' else 'acoustic'
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part) + '</h2><table><tr><th>Excerpt</th><th>Baseline</th><th>Plain lead</th><th>Diatonic runs</th><th>Chromatic approaches</th></tr>') body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part) + '</h2><table><tr><th>Excerpt</th><th>Song source</th><th>Previous close paraphrase</th><th>New theme</th><th>Theme with runs</th></tr>')
for window, offset, count in (('opening8', 0, 8), ('closing4', solo['count'] - 4, 4), ('full', 0, solo['count'])): for window, offset, count in (('opening8', 0, 8), ('closing4', solo['count'] - 4, 4), ('full', 0, solo['count'])):
first = solo['first'] + offset first = solo['first'] + offset
backing = tuple(audition.excerpt(old['parts'][p], first, count) for p in (other, 'vocal')) backing = tuple(audition.excerpt(old['parts'][p], first, count) for p in (other, 'vocal'))
body.append('<tr><th>' + window + '</th>') body.append('<tr><th>' + window + '</th>')
for stage in ('baseline', 'initial', 'refined', 'elaborated'): for stage in ('initial', 'close_paraphrase', 'refined', 'elaborated'):
foreground = audition.excerpt((old if stage == 'baseline' else song)['parts'][part], first, count) if stage in ('baseline', 'elaborated') else audition.excerpt(solo['lead_stages'][stage]['document'], offset, count) foreground = audition.excerpt(song['parts'][part], first, count) if stage=='elaborated' else audition.excerpt(old['solos'][part]['lead_stages']['refined']['document'] if stage=='close_paraphrase' else solo['lead_stages'][stage]['document'], offset, count)
prefix = f'{stage}/{name}_{part}_{window}' prefix = f'{stage}/{name}_{part}_{window}'
path = clip(prefix + '_fixed', foreground, F(solo['tempo']), backing) path = clip(prefix + '_fixed', foreground, F(solo['tempo']), backing)
solo_path = clip(prefix + '_solo', foreground, F(solo['tempo'])) solo_path = clip(prefix + '_solo', foreground, F(solo['tempo']))
@@ -119,6 +119,7 @@ def render(root):
clip(prefix + '_responsive', foreground, F(solo['tempo']), tuple(audition.excerpt(song['parts'][p], first, count) for p in (other, 'vocal'))) clip(prefix + '_responsive', foreground, F(solo['tempo']), tuple(audition.excerpt(song['parts'][p], first, count) for p in (other, 'vocal')))
body.append('</tr>') body.append('</tr>')
body.append('</table>') body.append('</table>')
body.append(f'<p><a href="paraphrase_baseline/{name}_{part}_full_solo.wav">Previous complete production solo</a></p>')
prefix = f'elaborated/{name}_{part}' prefix = f'elaborated/{name}_{part}'
(root / (prefix + '.txt')).write_text(solo['report']) (root / (prefix + '.txt')).write_text(solo['report'])
(root / (prefix + '.json')).write_text(json.dumps(solo['structure'], indent=2) + '\n') (root / (prefix + '.json')).write_text(json.dumps(solo['structure'], indent=2) + '\n')
@@ -141,7 +142,7 @@ def render(root):
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_lead_melody_auditions')) parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_melody_auditions'))
action = parser.add_mutually_exclusive_group(required=True) action = parser.add_mutually_exclusive_group(required=True)
action.add_argument('--capture', action='store_true') action.add_argument('--capture', action='store_true')
action.add_argument('--render', action='store_true') action.add_argument('--render', action='store_true')
+356 -287
View File
@@ -1,317 +1,386 @@
"""Complete paired lead ideas, realized by the existing phrase/output pipeline. """Source-phrase variations: edit actual music, then realize complete routes.
The bounded search branches on a whole timed hook or answer. Every pitch in The external lead references are audition controls, never production seeds.
that group is measured from one fixed origin, never from a previously repaired All notes use sounding coordinates and all time uses quarter-note Fractions.
note. The original identity survives harmonic adaptation and passage changes.
""" """
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import fractions import fractions
import itertools
import time
import generator.harmony
import generator.model import generator.model
import generator.solo.drafts as drafts import generator.fretboard
import generator.solo.harmonic as harmonic from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts
import generator.solo.policy as policy
import generator.solo.types as types
F = fractions.Fraction F = fractions.Fraction
def identities(references, incoming):
reference = references[0]
low, high = reference.features[0][1:]
source = tuple(n for n in reference.notes if low <= n.start < high)
rhythm = tuple(n.end - n.start for n in source)
if len(rhythm) != 4:
raise ValueError('lead reference requires a complete four-attack identifying figure')
rhythms = (rhythm, (rhythm[1], rhythm[2], rhythm[0], rhythm[3]), (F(1), F(1, 2), F(3, 2), F(1)))
shapes = ((0, 3, 2, 0), (0, -3, -2, 0), (0, 2, 1, -1), (0, 0, 2, 1))
result = [types.LeadIdentity(f'{reference.id}:lead:{i}:{j}', durations, degrees, reference.id)
for i, durations in enumerate(rhythms) for j, degrees in enumerate(shapes)]
if incoming.lead_identity:
result = [incoming.lead_identity, *(x for x in result if x != incoming.lead_identity)]
return tuple(result[:policy.DEFAULT.lead_identities])
def timings(start, length, durations, finish, harmony): def signature(notes):
"""Fit meter on an eighth-note grid; never compress into tiny tuplets.""" return tuple((n.start, n.end, n.pitch.number()) for n in notes)
cursor, result = start, []
ideal = [duration * length / sum(durations) * 2 for duration in durations]
ticks = [max(1, int(value)) for value in ideal] def shift(notes, octave):
available = int(length * 2) return tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave+octave),
if available < len(ticks) or length * 2 != available: position=None, role='source', event_id=n.event_id or n.sources[0]) for n in notes)
def available(harmony, start, end, *, members=False):
span = harmony.at(start)
allowed = types.permitted(harmony.choices, start, end)
if members:
allowed &= harmony.members(start, end)
if not span or not span.scale:
return () return ()
while sum(ticks) < available: return tuple(p for i in range(-35, 28) if 41 <= (p := types.scale_pitch(span.scale, i)).number() <= 76 and p.number()%12 in allowed)
index = max(range(len(ticks)), key=lambda i: (ideal[i] - ticks[i], -i))
ticks[index] += 1
while sum(ticks) > available: def adapt(notes, harmony):
index = max((i for i in range(len(ticks)) if ticks[i] > 1), key=lambda i: (ticks[i] - ideal[i], -i)) """Document required harmonic rewrites; never repair a sequence one step at a time."""
ticks[index] -= 1 result, edits = [], []
for duration in ticks: for index, note in enumerate(notes):
end = min(finish, cursor + F(duration, 2)) terminal = index == len(notes)-1
if cursor < end: choices = available(harmony,note.start,note.end,members=terminal or note.end-note.start >= 2)
# An authored rest in the harmony is not filled with a melodic fallback. pieces = [(note.start,note.end,choices)]
spans = [s for s in harmony.spans if s.start < end and s.end > cursor] if not choices:
if spans and all(s.scale for s in spans): pieces = [(a,b,available(harmony,a,b,members=terminal or b-a >= 2)) for _,a,b in harmony.segments(note)]
result.append((cursor, end)) for j,(start,end,domain) in enumerate(pieces):
cursor = end if not domain:
if harmony.at(start) and harmony.at(start).scale:
return (), ()
edits.append(types.DraftEdit('missing_harmony',start,end,(note.event_id,),(), 'explicit no-chord space'))
continue
pitch = min(domain,key=lambda p:(abs(p.number()-note.pitch.number()),p.number()))
identifier = note.event_id if j == 0 else note.event_id+f':harmonic:{j}'
actual = dataclasses.replace(note,start=start,end=end,pitch=pitch,event_id=identifier)
result.append(actual)
if (start,end,pitch) != (note.start,note.end,note.pitch):
edits.append(types.DraftEdit('harmonic_adaptation',start,end,(note.event_id,),(identifier,),
f'{note.pitch.label()} to {pitch.label()}: sounding duration / {harmony.at(start).chord}'))
if result:
last=result[-1]
domain=available(harmony,last.start,last.end,members=True)
if not domain:
return (),()
if last.pitch.number()%12 not in harmony.members(last.start,last.end):
pitch=min(domain,key=lambda p:(abs(p.number()-last.pitch.number()),p.number()))
result[-1]=dataclasses.replace(last,pitch=pitch)
edits.append(types.DraftEdit('harmonic_destination',last.start,last.end,(last.event_id,),(last.event_id,),
f'{last.pitch.label()} to {pitch.label()}: final sounding arrival before release'))
return tuple(result),tuple(edits)
def identifying(notes, source):
"""A complete heard figure, chosen by its musical content rather than its index."""
if not notes:
return ()
count = min(4,max(1,len(notes)//2))
windows = [notes[i:i+count] for i in range(len(notes)-count+1)]
def rank(group):
intervals = tuple(b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:]))
rhythm = {(n.end-n.start) for n in group}
# Favor a rhythm/turn with information; leave long holds available for development.
return (-len(rhythm),-len(set(intervals)),sum(max(F(),n.end-n.start-1) for n in group),group[0].start)
group = min(windows,key=rank)
return (types.IdentifyingFeature(source+':figure',tuple(n.event_id for n in group),
tuple((n.start-group[0].start,n.end-n.start) for n in group),
tuple(b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])),source),)
def variations(notes, source, harmony):
"""One purposeful timing or pitch edit; no generic replacement openings."""
result=[]
if source.pickup and notes and source.start==0:
length=-source.pickup[0].start
if notes[0].end-length>=F(1,2):
pickup=tuple(dataclasses.replace(n,start=n.start+length,end=n.end+length) for n in source.pickup)
changed=(*pickup,dataclasses.replace(notes[0],start=length),*notes[1:])
if harmony.evaluate(changed,complete=True).valid:
result.append((changed,(types.DraftEdit('pickup_entrance',F(),notes[0].end,(notes[0].event_id,),
tuple(n.event_id for n in pickup)+(notes[0].event_id,),source.id+': bring source pickup inside the solo and shorten its first hold'),)))
for i,(before,note) in enumerate(zip(notes,notes[1:]),1):
if before.end == note.start and before.end-before.start >= 1 and note.end-note.start >= F(1,2):
for displacement in (F(-1,2),F(1,2)):
time = note.start+displacement
if time-before.start < F(1,2) or note.end-time < F(1,2):
continue
changed=(*notes[:i-1],dataclasses.replace(before,end=time),dataclasses.replace(note,start=time),*notes[i+1:])
if harmony.evaluate(changed,complete=True).valid:
edit=types.DraftEdit('rhythmic_paraphrase',before.start,note.end,(before.event_id,note.event_id),(before.event_id,note.event_id),
f'{source.id}: retain both pitches and their order; move shared boundary {displacement} beat')
result.append((changed,(edit,)))
# Structural color is chosen in relation to the original turn, never a new random contour.
if 0 < i < len(notes)-1:
after=notes[i+1]
for p in available(harmony,note.start,note.end,members=note.end-note.start>=1):
if p == note.pitch or abs(p.number()-note.pitch.number())>2:
continue
direction=lambda a,b:(b>a)-(b<a)
if (direction(before.pitch.number(),p.number()),direction(p.number(),after.pitch.number())) != (direction(before.pitch.number(),note.pitch.number()),direction(note.pitch.number(),after.pitch.number())):
continue
changed=(*notes[:i],dataclasses.replace(note,pitch=p),*notes[i+1:])
if harmony.evaluate(changed,complete=True).valid:
edit=types.DraftEdit('melodic_paraphrase',note.start,note.end,(note.event_id,),(note.event_id,),f'{source.id}: {note.pitch.label()} to {p.label()}, retain rhythm and turn')
result.append((changed,(edit,)))
# Broaden or release an ending as an explicit whole-phrase alternative.
if notes and notes[-1].end-notes[-1].start >= F(3,2):
tail=dataclasses.replace(notes[-1],end=notes[-1].end-F(1,2))
result.append(((*notes[:-1],tail),(types.DraftEdit('ending_release',tail.end,notes[-1].end,(tail.event_id,),(tail.event_id,),source.id+': half-beat breath after the arrival'),)))
return tuple(result) return tuple(result)
def group_options(spans, degrees, register, harmony, previous, identifier, *, home=(), terminal=False):
"""Enumerate whole paired figures, including non-nearest melodic intervals."""
from generator.solo.realization import options
from generator.solo.planning import target_pitches
if not spans:
return ((0., ()),)
cache = getattr(harmony, 'lead_groups', None)
if cache is None:
harmony.lead_groups = cache = {}
key = (spans, degrees, register, previous.pitch if previous else None, identifier, tuple(n.pitch for n in home), terminal)
if key in cache:
return cache[key]
domains = []
for index, (start, end) in enumerate(spans):
available = options(harmony.choices, start, end, register)
if end - start >= policy.DEFAULT.long_note:
members = harmony.members(start, end)
available = tuple(p for p in available if p.number() % 12 in members)
if terminal and index == len(spans) - 1:
chord = next(c.span.chord for c in harmony.choices if c.span.start <= start < c.span.end)
eligible = target_pitches(chord, True) & harmony.members(start, end)
available = tuple(p for p in available if p.number() % 12 in eligible)
if not available:
return ()
domains.append(available)
collection = harmony.at(spans[0][0]).scale
result = []
for origin in domains[0]:
index = types.scale_index(collection, origin.number())
desired = tuple(origin.number() + n.pitch.number() - home[0].pitch.number() for n in home) if home else tuple(types.scale_pitch(collection, index + degree).number() for degree in degrees)
desired = desired[:len(spans)]
if len(desired) < len(spans):
continue
narrowed = [(origin,)] + [tuple(sorted(domain, key=lambda p: (abs(p.number() - target), p))[:2]) for domain, target in zip(domains[1:], desired[1:])]
for pitches in itertools.product(*narrowed):
actual = tuple(b.number() - a.number() for a, b in zip(pitches, pitches[1:]))
intended = tuple(b - a for a, b in zip(desired, desired[1:]))
# A return must supply evidence of the complete heard figure, not just
# its first interval. Harmonic adaptation may alter interval quality.
if home and any((a > 0) - (a < 0) != (b > 0) - (b < 0) or abs(a - b) > 2 for a, b in zip(actual, intended)):
continue
shape = sum(abs(a - b) / 12 + .5 * (((a > 0) - (a < 0)) != ((b > 0) - (b < 0))) for a, b in zip(actual, intended)) / max(1, len(actual))
if not home and shape > .45:
continue
notes = tuple(types.Note(start, end, pitch, (identifier,), 'motif', event_id=f'{identifier}:{start}') for (start, end), pitch in zip(spans, pitches))
stopped = sum(end - start >= 1 and p.number() % 12 not in harmony.at(start).roles.basic for (start, end), p in zip(spans, pitches)) / len(notes)
connection = min(1, abs(origin.number() - previous.pitch.number()) / 12) if previous else 0
result.append((shape + .3 * stopped + .15 * connection, notes))
result.sort(key=lambda v: (v[0], tuple(n.pitch for n in v[1])))
selected = tuple(result[:policy.DEFAULT.lead_realizations])
cache[key] = selected
return selected
def make_phrase(plan, notes, groups, identity, incoming, harmony, tempo, fit): def rhythm_catalog(library, meter):
from generator.solo import realization # Keep complete snippet objects. Slices below retain real rests and durations.
if not notes: return drafts.catalog(library,meter)
return None
first = next((g for g in groups if g[2] in ('statement', 'recall', 'answer_theme')), groups[0])
home_notes = tuple(n for n in notes if first[0] <= n.start < first[1]) def connection_patterns(start,end,entry,destination,rhythms,meter):
home = realization.make_identity(first[3], identity.id, home_notes, first[0], first[1], first[2], harmony) length=end-start
transformations, features, pairs = [], [], [] spans={( (F(),length), ):'held'}
parent = incoming.development.id if incoming.development else identity.id for identifier,pattern,total in rhythm_catalog(rhythms,meter):
for start, end, operation, group_id in groups: if total < length:
actual = tuple(n for n in notes if start <= n.start < end)
if not actual:
continue continue
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(actual, actual[1:])) # Retrieve every exact-duration window, including offbeat and cross-bar windows.
transformations.append(types.Transformation(group_id, parent, home.id, operation, start, end, tuple(n.event_id for n in actual), tuple((n.start - start, n.end - start) for n in actual), directions, (), identity.source)) for offset in sorted({F(),*(a for a,_ in pattern)}):
parent = group_id if offset+length>total:
if operation in ('statement', 'recall', 'answer_theme', 'repeat_with_harmonic_adaptation') and len(actual) == len(identity.degrees): continue
features.append(types.IdentifyingFeature(group_id + ':hook', tuple(n.event_id for n in actual), tuple((n.start - actual[0].start, n.end - n.start) for n in actual), tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:])), identity.id)) if any(a<offset<b or a<offset+length<b for a,b in pattern):
if operation == 'repeat_with_harmonic_adaptation': continue
pairs.extend((a.event_id, b.event_id) for a, b in zip(home_notes, actual)) selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
if 2<=len(selected)<=7 and selected[0][0]==0:
spans.setdefault(selected,identifier)
ranked=sequences.diverse(tuple(spans.items()),
lambda item:(abs(len(item[0])-float(length)*1.5),item[1]),
lambda item:(len(item[0]),any(a[1]!=b[0] for a,b in zip(item[0],item[0][1:]))),8)
result=[]
for pattern,identifier in ranked:
events=tuple(dataclasses.replace(entry,start=start+a,end=start+b,role='connection',
event_id=f'{entry.event_id}:route:{a}',position=None) for a,b in pattern)
if len(events)>=2:
result.append(((*events,destination),identifier))
return result
def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False):
"""Edit an exposed held tail or a complete interior span, with both endpoints known."""
fixed={e for f in features for e in f.events}
windows=[]
for i,(note,target) in enumerate(zip(notes,notes[1:])):
if note.event_id in fixed or note.end != target.start:
continue
if note.end-note.start>=F(3,2):
start=max(note.start+F(1,2),target.start-3)
windows.append((i,start,target,False))
# A source-held ending can arrive later via a related in-scale turn.
if notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2):
tail=notes[-1]
for duration in (F(1,2),F(1),F(3,2),F(2)):
target=dataclasses.replace(tail,start=tail.end-duration,event_id=tail.event_id+':arrival')
start=max(tail.start+F(1,2),target.start-3)
if target.start-start>=1:
windows.append((len(notes)-1,start,target,True))
result=[]
for i,start,target,terminal in windows:
entry=notes[i];end=target.start
prefix=dataclasses.replace(entry,end=start)
seed=dataclasses.replace(entry,start=start,event_id=entry.event_id+':tail',role='connection')
for pattern,rhythm_id in connection_patterns(start,end,seed,target,rhythms,meter):
for route in routes.candidates(pattern,{target.event_id},harmony,library,(41,76),'develop '+source.id):
if sustain_entry:
# The route owns its complete sounding entry, including the old hold.
joined=dataclasses.replace(route.notes[0],start=entry.start,event_id=entry.event_id)
route=dataclasses.replace(route,notes=(joined,*route.notes[1:]))
actual=(*notes[:i],*route.notes,*notes[i+1 if terminal else i+2:])
else:
actual=(*notes[:i],prefix,*route.notes,*notes[i+1 if terminal else i+2:])
if not harmony.evaluate(actual,complete=True).valid:
continue
if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])):
continue
route=dataclasses.replace(route,sources=(*route.sources,'rhythm:'+rhythm_id))
edit=types.DraftEdit('complete_route',start,target.start,(entry.event_id,),tuple(n.event_id for n in route.notes),
source.id+'; '+rhythm_id+'; '+('later terminal arrival on ' if terminal else 'lead into ')+target.pitch.label())
result.append((actual,edit,route))
# Whole routes compete; bounded diversity retains different locations and rhythms.
return sequences.diverse(result,lambda v:(abs(len(v[0])*4/float(source.end-source.start)-5),
sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(v[2].notes,v[2].notes[1:])),v[2].id),
lambda v:(v[1].start,v[2].destination.end-v[2].destination.start,len(v[2].notes)),8)
def make_phrase(source, original, refined, notes, edits, route_list, octave, part, harmony, tempo, seed):
from generator.solo import realization
features=identifying(refined,source.id)
# Only fully retained source turns become committed anchor events.
anchors=[] anchors=[]
for note, role in ((notes[0], 'opening'), *((n, 'intermediate') for n in notes[1:-1] if any(n.start == g[0] and g[2] == 'repeat_with_harmonic_adaptation' for g in groups)), (notes[-1], 'terminal')): for i,n in enumerate(notes):
span = harmony.at(note.start) if n.pitch.number()%12 not in harmony.members(n.start,n.end):
if note.pitch.number() % 12 not in span.roles.basic:
if role != 'terminal':
continue continue
role='terminal' if i==len(notes)-1 else 'opening' if i==0 else 'intermediate'
if role=='intermediate' and n.end-n.start<2:
continue
anchors.append(types.Anchor(n.event_id+':anchor',n.start,n.start,n.pitch,harmony.members(n.start,n.end),
(41,76),n.end-n.start,role,harmony.at(n.start).chord,hold_until=n.end))
if notes and (not anchors or anchors[-1].role!='terminal'):
return None return None
anchors.append(types.Anchor(note.event_id + ':anchor', note.start, note.start, note.pitch, span.roles.basic, plan.register, note.end - note.start, role, span.chord, hold_until=note.end)) identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,signature(notes))[:12]
last = notes[-1] motif=types.Motif(source.id,tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start) for n in refined),
passages = (types.ActivitySpan(plan.start, last.end, 'active', plan.activity, F('0.925')),) source.end-source.start,tuple(s.chord for s in harmony.spans if s.start<source.end and s.end>source.start),'song melody '+source.family)
if last.end < plan.end: home=realization.make_identity(identifier,source.id,refined,source.start,source.end,'source_variation',harmony)
passages += (types.ActivitySpan(last.end, plan.end, 'space', F(), F()),) transform=types.Transformation(identifier,source.id,source.family,'source_variation',source.start,source.end,
motif = types.Motif(home.id, home.notes, home.duration, tuple(s.chord for s in harmony.spans if s.start < first[1] and s.end > first[0]), 'invented paired lead: ' + identity.id) tuple(n.event_id for n in notes),tuple((n.start-source.start,n.end-source.start) for n in notes),preserves=(),source=source.id)
actual_plan = dataclasses.replace(plan, motif=motif, passages=passages, release=plan.end - last.end, landmarks=(), anchor_options=(tuple(anchors),), ending='breath' if last.end < plan.end else 'sustain', closure='closed') draft=types.MelodicDraft(identifier,source.start,source.end,notes,(transform,),
draft = types.MelodicDraft(plan.id + ':' + identity.id + ':' + policy.stable_rank(plan.seed, tuple(n.pitch for n in notes))[:10], plan.start, plan.end, notes, tuple(transformations), tuple(a.id.removesuffix(':anchor') for a in anchors), (), tuple(anchors), home, first[2], tuple(g[0] for g in groups[1:]), tuple(features), identity.source, motif, identity, tuple(pairs)) tuple(a.id.removesuffix(':anchor') for a in anchors),(),tuple(anchors),home,'source_variation',features=features,
phrase = realization.checked_phrase(actual_plan, draft, notes, (), incoming, harmony, tempo) reference=source.id,reference_motif=motif,source_phrase=source,octave_transfer=octave,
if phrase: source_links=tuple((n.event_id,n.sources) for n in notes))
state = dataclasses.replace(phrase.state, lead_identity=identity) spaces=[];cursor=source.start
# Different complete musical constructions compete before any ornament. for n in notes:
return dataclasses.replace(phrase, state=state, score=phrase.score + fit, initial=draft, refined=draft) if n.start>cursor:spaces.append(types.ActivitySpan(cursor,n.start,'space',F(),F()))
spaces.append(types.ActivitySpan(n.start,n.end,'active',F(5,4),F()))
cursor=n.end
if cursor<source.end:spaces.append(types.ActivitySpan(cursor,source.end,'space',F(),F()))
if not notes:spaces=[types.ActivitySpan(source.start,source.end,'space',F(),F())]
plan=types.PhrasePlan(identifier,source.start,source.end,motif,'source_variation','source_variation',None,(41,76),F(5,4),
source.end-cursor,tuple(spaces),(), 'breath' if cursor<source.end else 'sustain','closed',seed,source.id,source.start,source.end, (tuple(anchors),))
phrase=realization.checked_phrase(plan,draft,notes,(),types.ContinuationState(''),harmony,tempo,edits)
if phrase is None:
return None
# Source correspondence is evaluated once. Do not score the imported tune's
# characteristic leaps as generic contour defects or reward filling its rests.
source_by_id={n.event_id:n for n in original}
retained={n.event_id:n for n in notes if n.event_id in source_by_id}
deviation=sum(min(1,abs(n.pitch.number()-retained[k].pitch.number())/5)+
min(1,float(abs(n.start-retained[k].start))/2)+min(1,float(abs((n.end-n.start)-(retained[k].end-retained[k].start)))/2) for k,n in source_by_id.items() if k in retained)/max(1,len(source_by_id))
exposure=harmony.evaluate(notes,complete=True).exposure
density=len(notes)*4/max(.001,float(source.end-source.start))
score=3*deviation+2*exposure+.35*max(0,4-density,density-6)
source_draft=dataclasses.replace(draft,id=identifier+':source',notes=original,features=(),anchors=(),structural=())
refined_draft=dataclasses.replace(draft,id=identifier+':variation',notes=refined,features=features,anchors=(),structural=())
return dataclasses.replace(phrase,score=score,initial=source_draft,refined=refined_draft,routes=route_list)
def candidates(plan, measures, choices, incoming, tempo, context):
from generator.solo import realization, sequences def candidates(source, harmony, sequences_library, rhythms, meter, part, tempo, seed, *, stage='elaborated'):
harmony_key = ('lead_harmony', policy.stable_rank('harmony', choices))
if harmony_key not in context.resources:
context.resources[harmony_key] = harmonic.Harmony(choices)
harmony = context.resources[harmony_key]
active = [s for s in harmony.spans if s.scale and s.start < plan.end and s.end > plan.start]
if not active:
silent_plan = dataclasses.replace(plan, passages=(types.ActivitySpan(plan.start, plan.end, 'space', F(), F()),), release=plan.end-plan.start, landmarks=(), anchor_options=())
draft = types.MelodicDraft(plan.id + ':space', plan.start, plan.end, (), (), (), (), (), None, 'space')
phrase = realization.checked_phrase(silent_plan, draft, (), (), incoming, harmony, tempo)
return (dataclasses.replace(phrase, initial=draft, refined=draft),) if phrase else ()
finish = min(plan.end, active[-1].end)
boundaries, cursor = [], F()
for measure in measures:
length = generator.model.fraction(measure['duration'])
if plan.start <= cursor < finish:
boundaries.append((cursor, min(cursor + length, finish)))
cursor += length
result=[] result=[]
for identity in identities(context.references, incoming): for octave in range(-3,4):
states = [(0., (), (), ())] original=shift(source.notes,octave)
for index, (start, end) in enumerate(boundaries): if any(not generator.fretboard.single_positions_for_pitch(F(n.pitch.number())) for n in original):
final = index == len(boundaries) - 1
hook = index in (0, 2) and not final
returning = hook and index == 2
operation = ('recall' if incoming.lead_identity == identity else 'answer_theme' if incoming.lead_identity else 'statement') if index == 0 else 'repeat_with_harmonic_adaptation' if returning else 'close' if final else 'extend' if index >= 4 else 'answer'
# Paired answers retain the hook's direction of departure. Ending
# alternatives supply timing and pitches together, not inserted holds.
specs = [(identity.durations, identity.degrees)] if hook else [((F(1), F(1, 2), F(1, 2), F(1), F(1)), (0, 1, 2, 1, -1)), ((F(2), F(1, 2), F(1, 2), F(1)), (0, 1, 2, 1))]
if index >= 4 and not final:
reference = context.references[0]
profiles = [d for d in reference.development if d[1] == index * 4 and d[3]]
if profiles:
_, low, high, degrees = profiles[0]
source = tuple(n for n in reference.notes if low <= n.start < high)
specs = [(tuple(n.end - n.start for n in source), degrees)]
if final:
specs = [((F(1), F(1, 2), F(1, 2), F(2)), (0, -1, -2, -1)), ((F(1, 2), F(1, 2), F(1), F(1)), (0, 2, 1, 0))]
if end - start < 2:
specs = [((end - start,), (0,))]
elif end - start < 4:
specs = [((F(1, 2), end - start - F(1, 2)), (0, -1)), ((F(1), end - start - 1), (0, 1))]
following = []
for cost, notes, groups, home in states:
for variant, (durations, degrees) in enumerate(specs):
release = F(1) if final and variant == 1 and end - start >= 4 else F()
spans = timings(start, end - start - release, durations, end - release, harmony)
identifier = f'{plan.id}:{identity.id}:{index}:{variant}'
previous = notes[-1] if notes else incoming.last
heard = home if returning else incoming.home.notes if index == 0 and incoming.home and incoming.lead_identity == identity else ()
for local_cost, actual in group_options(spans, degrees, plan.register, harmony, previous, identifier, home=heard, terminal=final):
if not actual:
following.append((cost, notes, groups, home))
continue continue
following.append((cost + local_cost, (*notes, *actual), (*groups, (start, end, operation, identifier)), actual if index == 0 else home)) adapted,required=adapt(original,harmony)
states = sorted(following, key=lambda v: (v[0], tuple(n.pitch for n in v[1])))[:policy.DEFAULT.lead_realizations] if original and not adapted and any(harmony.at(n.start) and harmony.at(n.start).scale for n in original):
context.diagnostics['evaluated_extensions'] += len(following)
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
if not states:
context.diagnostics.setdefault('lead_failures', []).append({'phrase': plan.id, 'identity': identity.id, 'start': str(start), 'end': str(end), 'operation': operation, 'reason': 'no complete figure satisfies the harmonic, duration and return constraints within the bounded candidate pool'})
break
for cost, notes, groups, home in states:
if not notes:
continue continue
phrase = make_phrase(plan, notes, groups, identity, incoming, harmony, tempo, cost / max(1, len(groups))) variants=list(variations(adapted,dataclasses.replace(source,pickup=shift(source.pickup,octave)),harmony))
if required or not adapted:
variants.append((adapted,()))
# A whole preserved phrase is the baseline, not an accidental production fallback.
if not variants:
continue
variants=sequences.diverse(variants,lambda v:(len(v[1]),policy.stable_rank(seed,signature(v[0]))),
lambda v:(v[1][0].kind if v[1] else 'harmonic_adaptation',v[1][0].start if v[1] else source.start),8)
for refined,changes in variants:
options=[(refined,(),())]
if stage=='elaborated':
connections=elaborate(refined,identifying(refined,source.id),harmony,sequences_library,rhythms,meter,source)
options.extend((notes,(edit,),(route,)) for notes,edit,route in connections)
# A second, distinct source-held span may develop the same phrase.
# Reuse complete routes; never elaborate an already elaborated event.
for first_notes,first_edit,first_route in connections[:2]:
fixed=(*identifying(refined,source.id),types.IdentifyingFeature('route',
tuple(n.event_id for n in first_route.notes)+first_edit.removed,(),(),source.id))
second=elaborate(first_notes,fixed,harmony,sequences_library,rhythms,meter,source)
options.extend((actual,(first_edit,edit),(first_route,route)) for actual,edit,route in second[:2])
for notes,added,route_list in options:
phrase=make_phrase(source,original,refined,notes,(*required,*changes,*added),route_list,octave,part,harmony,tempo,seed)
if phrase: if phrase:
# A heard identity is a real option across passages, not obligatory
# perpetual development of whichever fragment happened most recently.
if incoming.lead_identity and identity != incoming.lead_identity:
phrase = dataclasses.replace(phrase, score=phrase.score + .15)
result.append(phrase) result.append(phrase)
context.diagnostics['generated_candidates'] += len(result) # Preserve register AND edit-location diversity, rather than eight near-copies.
return sequences.diverse(result, lambda p: (p.score, policy.stable_rank(plan.seed, p.draft.id)), lambda p: p.draft.lead_identity.id, policy.DEFAULT.phrase_alternatives) unique={signature(p.draft.notes):p for p in result}
return sequences.diverse(tuple(unique.values()),lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
lambda p:p.draft.octave_transfer,policy.DEFAULT.source_variations)
def approach_pitch(target, number):
"""Spell a semitone neighbor using the adjacent diatonic letter."""
letters = generator.model.LETTERS
step = 1 if number > target.number() else -1
index = letters.index(target.letter) + step
octave, degree = divmod(index, 7)
candidates = []
for letter, octave in ((letters[degree], target.octave + octave), (target.letter, target.octave)):
natural = generator.model.NATURAL[letter] + 12 * (octave + 1)
candidates.append(types.Pitch(letter, F(number - natural), octave))
# A natural above A-flat is clearer than B-double-flat; an ordinary
# leading tone still uses its adjacent letter (D-sharp into E).
return min(candidates, key=lambda p: abs(p.alter))
def chromatic(phrase, incoming, harmony, tempo): def clipped_sources(analysis,end):
from generator.solo import realization
notes = realization.emitted_notes(phrase)
protected = {*phrase.draft.protected, *phrase.draft.structural, *(e for f in phrase.draft.features for e in f.events)}
budget = min(policy.DEFAULT.chromatic_max_per_eight_bars, max(1, int((phrase.plan.end - phrase.plan.start) / 8)))
proposals = []
for index, (note, target) in enumerate(zip(notes, notes[1:])):
if note.event_id in protected or note.end - note.start != F(1, 2) or note.start % 1 != F(1, 2) or note.end != target.start or len(harmony.segments(note)) != 1:
continue
span, destination = harmony.at(note.start), harmony.at(target.start)
if not span or not span.scale or not destination or target.pitch.number() % 12 not in destination.roles.basic:
continue
for number in (target.pitch.number() - 1, target.pitch.number() + 1):
if number % 12 in span.scale.pitches or not phrase.plan.register[0] <= number <= phrase.plan.register[1]:
continue
pitch = approach_pitch(target.pitch, number)
treatment = types.HarmonicTreatment(note.event_id + ':chromatic', 'chromatic_approach', number, note.start, note.end, target.pitch.number(), target.start, F(), note.event_id, target.event_id)
smooth = abs(number - (notes[index - 1].pitch.number() if index else note.pitch.number()))
proposals.append((smooth, abs(number - note.pitch.number()), note.start, index, pitch, treatment))
chosen_times, used_routes = [], set()
previous_times = [t.start for t in incoming.treatments if t.kind == 'chromatic_approach']
original_score = phrase.score
for _, _, _, index, pitch, treatment in sorted(proposals, key=lambda p: p[:4]):
if len(chosen_times) >= budget or any(abs(treatment.start - t) < 8 for t in (*previous_times, *chosen_times)):
continue
route_ids = {r.id for r in phrase.routes if any(n.event_id == treatment.event for n in r.notes)}
if route_ids & used_routes:
continue
original = realization.emitted_notes(phrase)
changed = tuple(dataclasses.replace(n, pitch=pitch, position=None, role='chromatic_approach') if n.event_id == treatment.event else n for n in original)
contracts = (*phrase.state.treatments, treatment)
if not harmony.evaluate(changed, contracts, complete=True).valid:
continue
draft = realization.rebase_draft(phrase.draft, changed, harmony)
edit = types.DraftEdit('chromatic_approach', treatment.start, treatment.deadline, (treatment.event,), (treatment.event,), 'semitone resolution to ' + treatment.resolution_event)
checked = realization.checked_phrase(phrase.plan, draft, changed, contracts, incoming, harmony, tempo, (*phrase.edits, edit))
if checked:
routes = tuple(dataclasses.replace(r, notes=tuple(dataclasses.replace(n, pitch=pitch, position=None, role='chromatic_approach') if n.event_id == treatment.event else n for n in r.notes), relationship=r.relationship + ('; chromatic approach' if r.id in route_ids else '')) for r in phrase.routes)
phrase = dataclasses.replace(checked, score=original_score, initial=phrase.initial, refined=phrase.refined, routes=routes, state=dataclasses.replace(checked.state, lead_identity=phrase.state.lead_identity))
chosen_times.append(treatment.start)
used_routes.update(route_ids)
return phrase
def alternatives(plan, measures, choices, sequences, tempo, previous, context):
from generator.solo import realization
incoming = previous if isinstance(previous, types.ContinuationState) else types.ContinuationState('', last=previous)
context.resources[id(sequences)] = sequences
key = ('paired-lead', plan, incoming, policy.stable_rank('harmony', choices), tuple(generator.model.fraction(m['duration']) for m in measures), tempo, id(sequences), tuple(r.version for r in context.references), policy.describe()['fingerprint'], context.stage)
if key in context.results:
context.diagnostics['cache_hits'] += 1
return context.results[key]
started = time.monotonic()
context.diagnostics['searches'] += 1
complete = candidates(plan, measures, choices, incoming, tempo, context)
if not complete:
raise realization.SearchExhausted(f'{plan.id}: no complete paired lead reaches the terminal harmony within {policy.DEFAULT.lead_identities} identities / {policy.DEFAULT.lead_realizations} realizations; not proof of incompatibility')
harmony = harmonic.Harmony(choices)
result=[] result=[]
for phrase in complete: for source in analysis.source_phrases:
score, identity = phrase.score, phrase.state.lead_identity if source.start>=end:break
if context.stage != 'initial': finish=min(source.end,end)
# The complete plain tune is selected by the same score in all stages. notes=tuple(dataclasses.replace(n,start=max(F(),n.start),end=min(n.end,finish)) for n in source.notes if n.start<finish and n.end>0)
# Ornament quality cannot rescue a weak underlying idea. result.append(dataclasses.replace(source,end=finish,notes=notes,rests=tuple((a,min(b,finish)) for a,b in source.rests if a<finish)))
phrase = realization.elaborate(phrase, incoming, harmony, sequences, tempo, context)
diatonic = realization.rebase_draft(phrase.draft, realization.emitted_notes(phrase), harmony)
phrase = dataclasses.replace(phrase, refined=diatonic, score=score, state=dataclasses.replace(phrase.state, lead_identity=identity))
if context.stage == 'elaborated':
phrase = chromatic(phrase, incoming, harmony, tempo)
result.append(dataclasses.replace(phrase, score=score))
context.diagnostics['construction_seconds'] = context.diagnostics.get('construction_seconds', 0) + time.monotonic() - started
context.diagnostics.setdefault('complete_ideas', {})[policy.stable_rank('incoming', (plan.id, incoming))[:16]] = [{'id': p.draft.id, 'identity': p.draft.lead_identity.id if p.draft.lead_identity else 'space', 'score': p.score, 'attacks': len(p.initial.notes), 'return_pairs': len(p.draft.correspondence), 'edits': [e.kind for e in p.edits]} for p in result]
context.results[key] = tuple(result)
return tuple(result) return tuple(result)
def transition(first,second):
if not first or not first.decisions or not second.decisions:return 0.
a=first.state.last;b=second.decisions[0].notes[0]
leap=max(0,abs(a.pitch.number()-b.pitch.number())-7)/12
same_family=first.draft.source_phrase.family==second.draft.source_phrase.family
repeated_end=not same_family and first.anchors and second.anchors and first.anchors[-1].pitch==second.anchors[-1].pitch and first.anchors[-1].duration==second.anchors[-1].duration
return .2*leap+.15*bool(repeated_end)
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
from generator.solo import realization, themes
if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode)
context.resources[id(library)]=library
context.resources[id(rhythms)]=rhythms
harmony=harmonic.Harmony(choices)
sources=themes.windows(analysis,measures,end,harmony) if mode=='thematic' else clipped_sources(analysis,end)
seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE))
states=[(0.,())]
for source in sources:
meter=measures[min(len(measures)-1,int(source.start//generator.model.fraction(measures[0]['duration'])))]['time_signature']
following=[]
for cost,history in states:
incoming=history[-1].state if history else types.ContinuationState('')
key=(mode,source,incoming if mode=='thematic' else None,policy.stable_rank("harmony",choices),part,analysis.tempo,seed,stage,id(library),id(rhythms),policy.DEFAULT)
if key in context.results:
pool=context.results[key];context.diagnostics['cache_hits']+=1
else:
if mode=='thematic' and any(c.scale and c.span.start<source.end and c.span.end>source.start for c in choices):
pool=()
for batch in range(2):
pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed,stage=stage,batch=batch,final=source.end==end)
if pool:break
context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1
elif mode=='thematic':
pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed)
else:
pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage)
context.results[key]=pool;context.diagnostics['generated_candidates']+=len(pool)
context.diagnostics.setdefault('source_candidate_pools', {}).setdefault(source.id,[]).extend(
{'id':p.draft.id,'score':p.score,'octave':p.draft.octave_transfer,'attacks':len(p.draft.notes),'edits':[e.kind for e in p.edits],'source_family':source.family,'theme':p.state.home.id if p.state.home else None} for p in pool)
for candidate in pool:
# The thematic constructor has already placed all decisions from the
# actual incoming state; do not collapse its motif groups into one bar.
if mode=='thematic':
total=cost+candidate.score*float(source.end-source.start)/float(end)+transition(history[-1] if history else None,candidate)
following.append((total,(*history,candidate)))
continue
last=incoming.last
placements=placement.alternatives(tuple(n for d in candidate.decisions for n in d.notes),analysis.tempo,last,limit=2)
for travel,notes in placements:
decision=dataclasses.replace(candidate.decisions[0],notes=notes) if candidate.decisions else None
state=dataclasses.replace(candidate.state,notes=notes,last=notes[-1] if notes else last,travel=travel)
placed=dataclasses.replace(candidate,decisions=(decision,) if decision else (),state=state)
total=cost+candidate.score*float(source.end-source.start)/float(end)+.05*travel/max(1,len(notes))/2000+transition(history[-1] if history else None,placed)
following.append((total,(*history,placed)))
if not following:
raise realization.SearchExhausted(f'{part} {source.id}: {mode} feature, harmonic destination or continuation failed within the bounded search; not proof of incompatibility')
states=sequences.diverse(following,lambda v:(v[0],policy.stable_rank(seed,tuple((p.draft.id,p.state.last.position if p.state.last else None) for p in v[1]))),
lambda v:(v[1][-1].state.home.id if mode=='thematic' and v[1][-1].state.home else tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier)
return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'thematic_recomposition' if mode=='thematic' else 'source_phrase_variations','policy':policy.describe()['fingerprint']},
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in states)
def similarity(first,second):
"""Compare composition independently of octave and fingering; callbacks are explicit."""
def events(solo):
callbacks={e for p in solo.phrases if p.draft for f in p.draft.features if f.id.endswith(':callback') for e in f.events}
return tuple(n for p in solo.phrases if p.plan.relationship!='callback' for d in p.decisions for n in d.notes if n.event_id not in callbacks)
a,b=events(first),events(second)
def tokens(notes):
return {(n.start,n.end,n.pitch.number()%12) for n in notes}
left,right=tokens(a),tokens(b)
literal=len(left&right)/max(1,min(len(left),len(right)))
def runs(notes):
return {tuple((n.start-notes[i].start,n.end-n.start,n.pitch.number()-notes[i].pitch.number()) for n in notes[i:i+4]) for i in range(len(notes)-3)}
left,right=runs(a),runs(b)
interval=len(left&right)/max(1,min(len(left),len(right)))
return .65*literal+.35*interval
+49 -11
View File
@@ -19,25 +19,63 @@ def transition(previous, current, duration, tempo):
# Movement is a preference, not an unsupported claim about a player's limits. # Movement is a preference, not an unsupported claim about a player's limits.
return round(100 * distance / max(seconds, 0.05)) return round(100 * distance / max(seconds, 0.05))
def hand_positions(position):
return tuple(h for h in range(1, 10) if h <= position.fret <= h + 3)
@functools.lru_cache(maxsize=512)
def position_rank(position):
from generator.solo.policy import stable_rank
return stable_rank('guitar-position', position)
def stable_path(path):
return tuple(position_rank(n.position) for n in path)
@functools.lru_cache(maxsize=4096) @functools.lru_cache(maxsize=4096)
def place(notes: tuple[generator.solo.types.Note, ...], tempo: fractions.Fraction, previous: generator.solo.types.Note | None = None) -> tuple[int, tuple[generator.solo.types.Note, ...]] | None: def alternatives(notes, tempo, previous=None, limit=2):
states = [(0, ())] """Phrase paths with a four-fret hand region and distinct endpoint choices."""
states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)]
for note in notes: for note in notes:
following = [] following = {}
for position in positions(note.pitch.number()): for position in positions(note.pitch.number()):
current = dataclasses.replace(note, position=position) current = dataclasses.replace(note, position=position)
candidates = [] for hand in hand_positions(position):
for cost, path in states: best = None
for cost, path, old_hand in states:
prior = path[-1] if path else previous prior = path[-1] if path else previous
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start): if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start):
continue continue
candidates.append((cost + (0 if note.continuation else travel(prior, current, tempo)), (*path, current))) movement = 0
if candidates: if prior and not note.continuation:
following.append(min(candidates, key=lambda item: (item[0], tuple(n.position for n in item[1])))) seconds = max(.05, float((note.start-prior.start)*60/tempo))
distance = abs(hand-old_hand) if old_hand is not None else 0
distance += .25*abs(position.fret-prior.position.fret) + .5*abs(position.string_number-prior.position.string_number)
movement = round(100*distance/seconds)
candidate = (cost+movement, (*path,current), hand)
if best is None or candidate[0] < best[0] or candidate[0] == best[0] and stable_path(candidate[1]) < stable_path(best[1]):
best = candidate
if best is not None:
following[position,hand] = best
if not following: if not following:
return None return ()
states = following states = list(following.values())
return min(states, key=lambda item: (item[0], tuple(n.position for n in item[1]))) # Endpoints survive so the following phrase can choose a better transition.
selected, seen = [], set()
for cost,path,hand in sorted(states,key=lambda v:(v[0],stable_path(v[1]))):
endpoint = path[-1].position if path else None
if endpoint in seen:
continue
seen.add(endpoint); selected.append((cost,path))
if len(selected) == limit:
break
return tuple(selected)
@functools.lru_cache(maxsize=4096)
def place(notes, tempo, previous=None):
result = alternatives(notes,tempo,previous,1)
return result[0] if result else None
@functools.lru_cache(maxsize=128) @functools.lru_cache(maxsize=128)
def positions(number: int): def positions(number: int):
+8 -3
View File
@@ -33,9 +33,14 @@ class Policy:
occupancy_deadband: float = 0.1 occupancy_deadband: float = 0.1
lead_activity_range: tuple[float, float] = (1.0, 1.5) lead_activity_range: tuple[float, float] = (1.0, 1.5)
lead_occupancy_range: tuple[float, float] = (0.85, 1.0) lead_occupancy_range: tuple[float, float] = (0.85, 1.0)
lead_identities: int = 12 source_variations: int = 8
lead_realizations: int = 2 chorus_frontier: int = 8
chromatic_max_per_eight_bars: int = 4 theme_copy_threshold: float = 0.8
theme_copy_beats: float = 8.0
theme_copy_attacks: int = 4
theme_seed_limit: int = 8
theme_unit_alternatives: int = 8
composition_version: str = 'thematic-recomposition-1'
# Preserve the former default root seeds while decoupling them from policy edits. # Preserve the former default root seeds while decoupling them from policy edits.
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1' SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
+2 -3
View File
@@ -524,7 +524,7 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
return None return None
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0)) 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)) features.update(generator.solo.drafts.relationship_features(draft, notes))
if draft.lead_identity: if draft.lead_identity or draft.source_phrase:
supported = {e for feature in draft.features for e in feature.events} supported = {e for feature in draft.features for e in feature.events}
motions = [(a, b) for a, b in zip(notes, notes[1:]) if not (a.event_id in supported and b.event_id in supported)] motions = [(a, b) for a, b in zip(notes, notes[1:]) if not (a.event_id in supported and b.event_id in supported)]
features['contour'] = sum(min(1, max(0, abs(b.pitch.number() - a.pitch.number()) - 5) / 12) for a, b in motions) / max(1, len(motions)) features['contour'] = sum(min(1, max(0, abs(b.pitch.number() - a.pitch.number()) - 5) / 12) for a, b in motions) / max(1, len(motions))
@@ -861,8 +861,7 @@ def revise_pair(first, second, incoming, choices, sequences, tempo, context):
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None): def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
context = context if context is not None else SearchContext() context = context if context is not None else SearchContext()
if plan.treatment == 'lead': if plan.treatment == 'lead':
from generator.solo import leads raise ValueError('generic lead identities are retired; use composer.compose for source variations')
return leads.alternatives(plan, measures, choices, sequences, tempo, previous, context)
# Keep resources alive while identity-keyed cache entries exist. A later # Keep resources alive while identity-keyed cache entries exist. A later
# library cannot accidentally reuse the identity of a collected object. # library cannot accidentally reuse the identity of a collected object.
context.resources[id(sequences)] = sequences context.resources[id(sequences)] = sequences
+14 -4
View File
@@ -93,14 +93,24 @@ def identifying_features(notes, source):
def feature_error(feature, notes): def feature_error(feature, notes):
by_id = {n.event_id: n for n in notes} by_id = {n.event_id: n for n in notes}
actual = [by_id.get(identity) for identity in feature.events] actual = [by_id.get(identity) for identity in feature.events]
if not all(actual): if not actual or not all(actual):
return 1.0 return 1.0
first = actual[0].start first = actual[0].start
rhythm = tuple((n.start - first, n.end - n.start) for n in actual) rhythm = tuple((n.start - first, n.end - n.start) for n in actual)
movement = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:])) movement = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:]))
timing = sum(a != b for a, b in zip(rhythm, feature.rhythm)) / max(1, len(rhythm)) errors = []
contour = sum(((a > 0) - (a < 0)) != ((b > 0) - (b < 0)) or abs(a - b) > 2 for a, b in zip(movement, feature.intervals)) / max(1, len(movement)) if 'rhythm' in feature.preserves:
return (timing + contour) / 2 errors.append(1.0 if len(rhythm) != len(feature.rhythm) else sum(a != b for a,b in zip(rhythm,feature.rhythm))/max(1,len(rhythm)))
if 'intervals' in feature.preserves or 'contour' in feature.preserves:
if len(movement) != len(feature.intervals) or not movement and feature.intervals:
errors.append(1.0)
else:
errors.append(sum(((a>0)-(a<0)) != ((b>0)-(b<0)) or 'intervals' in feature.preserves and abs(a-b)>2 for a,b in zip(movement,feature.intervals))/max(1,len(movement)))
if 'exact_intervals' in feature.preserves:
errors.append(float(movement != feature.intervals))
if 'pitches' in feature.preserves:
errors.append(float(tuple(n.pitch.number() for n in actual) != feature.pitches))
return max(errors,default=0.0)
def relationship(notes, reference): def relationship(notes, reference):
"""One joint relationship cost: timed movement, not absolute-pitch copying.""" """One joint relationship cost: timed movement, not absolute-pitch copying."""
+36 -4
View File
@@ -12,6 +12,8 @@ import generator.scales.timeline
import generator.solo.types import generator.solo.types
import generator.solo.harmonic import generator.solo.harmonic
import generator.solo.drafts import generator.solo.drafts
import generator.solo.similarity
import generator.solo.references
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str: def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str:
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo') region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
@@ -27,12 +29,18 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
plan = phrase.plan plan = phrase.plan
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources))) lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}') lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}')
if plan.treatment == 'lead': if plan.treatment in ('source_variation', 'thematic_recomposition'):
lines.append(' Lead range: 4–6 attacks per four beats across the passage; active occupancy 85–100%; local breaths and runs allowed.') lines.append(' Activity preference: 4–6 attacks per four beats; source phrasing takes precedence. Occupancy is diagnostic, not a target.')
if phrase.draft: if phrase.draft:
draft = 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()}') 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()}')
lines.append(f' Lead reference: {draft.reference}; selected features: ' + ', '.join(f.id for f in draft.features)) lines.append(f' Lead reference: {draft.reference}; selected features: ' + ', '.join(f.id for f in draft.features))
if draft.source_phrase:
lines.append(f' Source phrase {draft.source_phrase.id}; octave transfer {draft.octave_transfer:+}; source rests {draft.source_phrase.rests}; source-event links {draft.source_links}')
if draft.source_reference:
ref=draft.source_reference
lines.append(f' Source influence {ref.id}: {ref.treatment}; source {ref.source_id}, beats {ref.start}-{ref.end}. Later developments refer to solo theme {phrase.state.home.id}.')
lines.append(' Retained contracts: '+', '.join(f'{f.id}: {f.preserves}' for f in draft.features))
for route in phrase.routes: for route in phrase.routes:
lines.append(f' Complete route {route.id}: {route.notes[0].start}-{route.destination.start}; destination {route.destination.pitch.label()}; {route.relationship}; sources {route.sources}') lines.append(f' Complete route {route.id}: {route.notes[0].start}-{route.destination.start}; destination {route.destination.pitch.label()}; {route.relationship}; sources {route.sources}')
for transform in draft.transformations: for transform in draft.transformations:
@@ -62,7 +70,7 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
choice = next(c for c in solo.choices if c.span.start <= note.start < c.span.end) choice = next(c for c in solo.choices if c.span.start <= note.start < c.span.end)
root = generator.scales.selection.root_number(choice.span.chord) root = generator.scales.selection.root_number(choice.span.chord)
degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12] degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12]
function = 'chord degree ' + '/'.join(degrees) if degrees else 'chromatic approach' if note.role == 'chromatic_approach' else 'in-collection connection' function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection'
lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}') lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}')
lines.append('\nSounding harmonic spans (ties merged before classification):') lines.append('\nSounding harmonic spans (ties merged before classification):')
for event in detail['harmonic_roles']: for event in detail['harmonic_roles']:
@@ -99,6 +107,30 @@ def structure(solo):
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}) 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})
entry['lead_stages'] = {name: {'id': draft.id, 'attacks': len(draft.notes), 'notes': rational([dataclasses.asdict(n) for n in draft.notes])} for name, draft in (('initial', phrase.initial), ('refined', phrase.refined)) if draft} entry['lead_stages'] = {name: {'id': draft.id, 'attacks': len(draft.notes), 'notes': rational([dataclasses.asdict(n) for n in draft.notes])} for name, draft in (('initial', phrase.initial), ('refined', phrase.refined)) if draft}
entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes]) entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes])
if phrase.initial and phrase.refined:
entry['head_comparison_plain']=generator.solo.similarity.compare(phrase.refined.notes,phrase.initial.notes)
entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),phrase.initial.notes)
if phrase.state and phrase.state.home:
entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home)
entry['score'] = phrase.score entry['score'] = phrase.score
phrases.append(entry) phrases.append(entry)
return {'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'harmonic_roles': harmony.describe(solo.notes(), contracts), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])} return {'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), '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])}
def fretboard_report(solo):
import collections
import generator.fretboard
notes=solo.notes()
attacks=collections.Counter(); durations=collections.Counter(); forced=optional=0
for note in notes:
string=note.position.string_number
attacks[string]+=1;durations[string]+=float(note.end-note.start)
if string==1:
if len(generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())))==1:forced+=1
else:optional+=1
total=sum(durations.values())
return {'attacks_by_string':dict(sorted(attacks.items())),
'duration_fraction_by_string':{s:d/max(.001,total) for s,d in sorted(durations.items())},
'first_string_forced':forced,'first_string_optional':optional,
'range_midi': [min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)] if notes else [],
'cross_bar_sounding_events':sum(any(n.start < t < n.end for t in {c.span.start for c in solo.choices if c.span.beat == 1}) for n in notes)}
+16 -3
View File
@@ -5,6 +5,7 @@ import dataclasses
import fractions import fractions
import generator.fretboard import generator.fretboard
import generator.solo.placement
import generator.solo.policy import generator.solo.policy
import generator.solo.sequences import generator.solo.sequences
import generator.solo.types as types import generator.solo.types as types
@@ -25,12 +26,24 @@ def advance(scale, previous, step):
return tuple(outgoing[abs(step) - 1:abs(step)]) return tuple(outgoing[abs(step) - 1:abs(step)])
def candidates(notes, fixed, harmony, library, register, relationship='connect theme'): def candidates(notes, fixed, harmony, library, register, relationship='connect theme'):
# Cache concrete routes only with their endpoints, timing, scale context and
# vocabulary. The per-Harmony cache cannot leak across collection changes.
if not hasattr(harmony,'route_cache'):
harmony.route_cache={}
harmony.route_libraries={}
harmony.route_libraries[id(library)]=library
key=(notes,frozenset(fixed),id(library),register)
if key not in harmony.route_cache:
harmony.route_cache[key]=_candidates(notes,fixed,harmony,library,register)
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.route_cache[key])
def _candidates(notes, fixed, harmony, library, register):
"""Every returned candidate includes its entry, complete rhythm and destination. """Every returned candidate includes its entry, complete rhythm and destination.
Callers validate the whole surrounding sounding context and guitar path. Callers validate the whole surrounding sounding context and guitar path.
This function applies exact scale paths, fixed events and range constraints. This function applies exact scale paths, fixed events and range constraints.
""" """
if not 3 <= len(notes) <= 16: if not 2 <= len(notes) <= 16:
return () return ()
first, destination = notes[0], notes[-1] first, destination = notes[0], notes[-1]
span = harmony.at(first.start) span = harmony.at(first.start)
@@ -62,14 +75,14 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
break break
if (template.event_id in fixed or template is destination) and pitch.number() != template.pitch.number(): if (template.event_id in fixed or template is destination) and pitch.number() != template.pitch.number():
break break
if not generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch.number())): if not generator.solo.placement.positions(pitch.number()):
break break
realized.append(dataclasses.replace(template, pitch=template.pitch if pitch.number() == template.pitch.number() else pitch, position=None)) realized.append(dataclasses.replace(template, pitch=template.pitch if pitch.number() == template.pitch.number() else pitch, position=None))
if len(realized) != len(notes): if len(realized) != len(notes):
continue continue
route_spans = tuple((max(first.start, c.span.start), min(destination.end, c.span.end), c.scale.label()) for c in harmony.choices if c.scale and c.span.start < destination.end and c.span.end > first.start) route_spans = tuple((max(first.start, c.span.start), min(destination.end, c.span.end), c.scale.label()) for c in harmony.choices if c.scale and c.span.start < destination.end and c.span.end > first.start)
identity = generator.solo.policy.stable_rank('complete-route', (tuple(realized), path.sources))[:16] identity = generator.solo.policy.stable_rank('complete-route', (tuple(realized), path.sources))[:16]
result.append(types.RouteCandidate(identity, tuple(realized), destination, route_spans, relationship, path.sources)) result.append(types.RouteCandidate(identity, tuple(realized), destination, route_spans, 'connect theme', path.sources))
return generator.solo.sequences.diverse(result, return generator.solo.sequences.diverse(result,
lambda r: (sum(abs(b.pitch.number() - a.pitch.number()) for a, b in zip(r.notes, r.notes[1:])), r.id), lambda r: (sum(abs(b.pitch.number() - a.pitch.number()) for a, b in zip(r.notes, r.notes[1:])), r.id),
lambda r: generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(r.notes, r.notes[1:]))), lambda r: generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(r.notes, r.notes[1:]))),
+3 -1
View File
@@ -81,7 +81,7 @@ class Evaluation:
deviation = max(0, abs(attacks - expected) - max(tolerance, generator.solo.policy.DEFAULT.activity_deadband * length / self.duration)) deviation = max(0, abs(attacks - expected) - max(tolerance, generator.solo.policy.DEFAULT.activity_deadband * length / self.duration))
activity += length * min(1, deviation / max(1, expected)) activity += length * min(1, deviation / max(1, expected))
occupancy += length * max(0, abs(occupied / length - occupied_goal) - generator.solo.policy.DEFAULT.occupancy_deadband) occupancy += length * max(0, abs(occupied / length - occupied_goal) - generator.solo.policy.DEFAULT.occupancy_deadband)
if self.plan.treatment == 'lead': if self.plan.treatment in ('lead', 'source_variation', 'thematic_recomposition'):
# One broad range over the complete thought; quiet bars are not failed # One broad range over the complete thought; quiet bars are not failed
# local quotas. Missing harmony and explicit release are excluded here. # local quotas. Missing harmony and explicit release are excluded here.
active = tuple(window for window in self.windows if window[4] > 0) active = tuple(window for window in self.windows if window[4] > 0)
@@ -92,6 +92,8 @@ class Evaluation:
activity = length * corridor(attacks / max(length, .001), low, high) / high activity = length * corridor(attacks / max(length, .001), low, high) / high
low, high = generator.solo.policy.DEFAULT.lead_occupancy_range low, high = generator.solo.policy.DEFAULT.lead_occupancy_range
occupancy = length * corridor(occupied / max(length, .001), low, high) occupancy = length * corridor(occupied / max(length, .001), low, high)
if self.plan.treatment in ('source_variation', 'thematic_recomposition'):
occupancy = 0.0 # Space belongs to the source and its explicit edits.
start, end = float(self.plan.start), float(self.until) start, end = float(self.plan.start), float(self.until)
durations = [max(0, min(b, end) - max(a, start)) for a, b, _ in sounded] durations = [max(0, min(b, end) - max(a, start)) for a, b, _ in sounded]
register = sum(length * min(1, corridor(pitch, *self.plan.register) / 12) for (_, _, pitch), length in zip(sounded, durations)) / max(sum(durations), 0.001) register = sum(length * min(1, corridor(pitch, *self.plan.register) / 12) for (_, _, pitch), length in zip(sounded, durations)) / max(sum(durations), 0.001)
+92
View File
@@ -0,0 +1,92 @@
"""Duration-aware comparisons of realized music, independent of event labels."""
from __future__ import annotations
import functools
from generator.solo import types, policy
def events(notes):
return tuple((float(n.start),float(n.end),n.pitch.number()) for n in types.sounding_notes(notes))
def occupancy(left, right, modulo=False):
"""Intersection of pitch functions; repeated attacks cannot dilute a hold."""
i=j=0; total=0.
while i<len(left) and j<len(right):
a,b=left[i],right[j]
same = a[2]%12==b[2]%12 if modulo else a[2]==b[2]
if same:
total+=max(0.,min(a[1],b[1])-max(a[0],b[0]))
if a[1]<=b[1]:i+=1
else:j+=1
return total
def interval_match(left, right):
"""Longest exact interval/rhythm fragment, allowing relocation/transposition."""
def tokens(notes):
return tuple((b[0]-a[0],a[1]-a[0],b[2]-a[2]) for a,b in zip(notes,notes[1:]))
a,b=tokens(left),tokens(right); previous={}; best=(0.,0)
for i,x in enumerate(a):
current={}
for j,y in enumerate(b):
if x!=y:continue
count=previous.get(j-1,0)+1
current[j]=count
# The last note's duration also belongs to the matching fragment.
tail=min(left[i+1][1]-left[i+1][0],right[j+1][1]-right[j+1][0])
duration=left[i+1][0]+tail-left[i+1-count][0]
best=max(best,(duration,count+1))
previous=current
return best
@functools.lru_cache(maxsize=8192)
def compare_events(candidate, source):
denominator=sum(b-a for a,b,_ in source)
if not denominator:
return dict(pitch_time=0.,octave_insensitive=0.,attack_agreement=0.,arrival_agreement=0.,
interval_rhythm_coverage=0.,longest_fragment_beats=0.,longest_fragment_attacks=0)
length,count=interval_match(candidate,source)
attacks={(a,b,p%12) for a,b,p in candidate}
arrivals=[n for n in source if n[1]-n[0]>=2]
return dict(pitch_time=occupancy(candidate,source)/denominator,
octave_insensitive=occupancy(candidate,source,True)/denominator,
attack_agreement=sum((a,b,p%12) in attacks for a,b,p in source)/max(1,len(source)),
arrival_agreement=sum((a,b,p%12) in attacks for a,b,p in arrivals)/max(1,len(arrivals)),
interval_rhythm_coverage=min(1.,length/denominator),longest_fragment_beats=length,longest_fragment_attacks=count)
def compare(candidate, source):
return compare_events(events(candidate),events(source))
@functools.lru_cache(maxsize=8192)
def copy_evidence(candidate, source, start, end):
"""Scan source-aligned windows, without an added-event denominator advantage."""
minimum=policy.DEFAULT.theme_copy_beats
threshold=policy.DEFAULT.theme_copy_threshold
fragment,count=interval_match(candidate,source)
copied=fragment>=minimum and count>=policy.DEFAULT.theme_copy_attacks
best=0.; best_offset=0.
if end-start>=minimum and candidate:
windows=sorted({start,*(a for a,_,_ in candidate if start<=a<=end-minimum)})
for left in windows:
right=min(end,left+minimum)
offsets={a[0]-b[0] for a in candidate if left<=a[0]<left+2 for b in source}
for offset in sorted(offsets):
window=tuple((max(left,a+offset),min(right,b+offset),p) for a,b,p in source if a+offset<right and b+offset>left)
count_in=sum(left<=a+offset<right for a,_,_ in source)
duration=sum(b-a for a,b,_ in window)
if count_in<policy.DEFAULT.theme_copy_attacks or duration<minimum/2:
continue
score=occupancy(candidate,window,True)/duration
if score>best:best,best_offset=score,offset
if score>=threshold:copied=True
return {'near_copy':copied,'aligned_occupancy':best,'alignment_offset':best_offset,
'longest_fragment_beats':fragment,'longest_fragment_attacks':count}
def near_copy(notes, source, start, end):
return copy_evidence(events(notes),events(source),float(start),float(end))
+395
View File
@@ -0,0 +1,395 @@
"""Thematic transformations used by the existing complete-draft composer.
Source features initialize a heard theme. Subsequent complete routes develop
that realization, conditioned on the actual incoming musical state.
"""
from __future__ import annotations
import dataclasses
from fractions import Fraction as F
import generator.model
import generator.harmony
from generator.solo import types, policy, sequences, routes, references, similarity
def source_notes(analysis):
return tuple(dataclasses.replace(n,start=max(F(),n.start-analysis.body_offset),end=n.end-analysis.body_offset,
event_id=n.sources[0],position=None) for n in analysis.melody if n.end>analysis.body_offset)
def windows(analysis, measures, end, harmony):
"""Existing phrase representation; source extraction seams do not close it."""
bounds=[F()]
for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration']))
melody=source_notes(analysis); result=[]; index=0
while bounds[index]<end:
stop=min(index+4,len(measures))
# Permit an extension through a cadence rather than closing on its dominant.
last=harmony.at(min(end,bounds[stop])-F(1,100))
if last and last.chord!='N.C.' and generator.harmony.degrees(generator.harmony.parse_symbol(last.chord)).get(7)==-1 and generator.harmony.degrees(generator.harmony.parse_symbol(last.chord)).get(3)==0:
for size in (6,8):
candidate=min(index+size,len(measures)); span=harmony.at(min(end,bounds[candidate])-F(1,100))
if span and span.chord!='N.C.' and generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)).get(3)==-1:
stop=candidate;break
start,finish=bounds[index],min(end,bounds[stop])
notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.start<start)
for n in melody if n.start<finish and n.end>start)
result.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),''))
index=stop
# Explicit missing harmony interrupts sounding material, not thematic memory.
divided=[]
for window in result:
cuts=sorted({window.start,window.end,*(max(window.start,s.start) for s in harmony.spans if not s.scale and s.start<window.end and s.end>window.start),*(min(window.end,s.end) for s in harmony.spans if not s.scale and s.start<window.end and s.end>window.start)})
for start,finish in zip(cuts,cuts[1:]):
notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.start<start) for n in window.notes if n.start<finish and n.end>start)
divided.append(dataclasses.replace(window,id=f'theme_window_{start}_{finish}',start=start,end=finish,notes=notes))
return tuple(divided)
def silence(source,incoming,part,harmony,tempo,seed):
from generator.solo import realization
motif=types.Motif('intentional_space',(),source.end-source.start,(),'explicit missing harmony')
plan=types.PhrasePlan(part+'_'+source.id,source.start,source.end,motif,'space','thematic_recomposition',None,(41,76),F(),F(),
(types.ActivitySpan(source.start,source.end,'space',F(),F()),),passage_id=source.id,passage_start=source.start,passage_end=source.end)
draft=types.MelodicDraft(plan.id,source.start,source.end,(),(),(),(),(),incoming.home,'space',source_phrase=source,source_reference=incoming.source_reference)
phrase=realization.checked_phrase(plan,draft,(),incoming.treatments,incoming,harmony,tempo)
if phrase is None:return ()
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks)
original=dataclasses.replace(draft,notes=source.notes)
return (dataclasses.replace(phrase,state=state,initial=original,refined=draft,score=0.),)
def short_fragment(source,incoming,part,harmony,tempo,seed,final):
"""An explicitly reduced pickup around missing harmony; not a new theme."""
from generator.solo import leads,realization
reference=incoming.home.notes if incoming.home else source.notes
if not reference:return ()
template=dataclasses.replace(reference[0],start=source.start,end=source.end,position=None,continuation=False,event_id=source.id+':reduction')
result=[]
for pitch in pitches(harmony,template,incoming.last.pitch.number() if incoming.last else template.pitch.number(),members=True):
note=dataclasses.replace(template,pitch=pitch)
motif=types.Motif('reduction',reference,source.end-source.start,(),'explicit short fragment; no claim of full motif fidelity')
anchor=types.Anchor(note.event_id+':arrival',note.start,note.start,pitch,harmony.members(note.start,note.end),(41,76),note.end-note.start,'terminal' if final else 'intermediate',harmony.at(note.start).chord,hold_until=note.end)
transform=types.Transformation(note.event_id,reference[0].event_id,incoming.home.id if incoming.home else '', 'fragment',note.start,note.end,(note.event_id,),((F(),note.end-note.start),),preserves=())
draft=types.MelodicDraft(part+'_'+note.event_id,source.start,source.end,(note,),(transform,),(note.event_id,),(),(anchor,),incoming.home,'fragment',source_phrase=source,source_reference=incoming.source_reference)
plan=types.PhrasePlan(draft.id,source.start,source.end,motif,'fragment','thematic_recomposition',None,(41,76),F(5,4),F(),ending='sustain' if final else 'connected',closure='closed' if final else 'open',passage_id=source.id,passage_start=source.start,passage_end=source.end)
phrase=realization.checked_phrase(plan,draft,(note,),incoming.treatments,incoming,harmony,tempo)
if phrase:
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks)
result.append(dataclasses.replace(phrase,state=state,initial=draft,refined=draft))
return tuple(result)
def seeds(analysis, bar, seed):
melody=tuple(dataclasses.replace(n,start=n.start-analysis.body_offset,end=n.end-analysis.body_offset,
event_id=n.sources[0]) for n in analysis.melody)
groups=[]
for i in range(len(melody)):
for count in ((len(melody),) if len(melody)<3 else (3,4,5)):
group=melody[i:i+count]
if len(group)!=count or group[-1].end-group[0].start>max(bar,F(3)) or group[0].start>=8*bar:
continue
local=tuple(dataclasses.replace(n,start=n.start-group[0].start,end=n.end-group[0].start) for n in group)
parent=next((s.id for s in analysis.source_phrases if s.start<=max(0,group[0].start)<s.end),'pickup')
for treatment in ('rhythmic_seed','contour_seed','opening_fragment','characteristic_interval'):
identity=policy.stable_rank(seed,(group,treatment))[:12]
groups.append(types.SourceReference(identity,parent,group[0].start,group[-1].end,local,treatment))
def rank(ref):
rhythm={(n.start,n.end-n.start) for n in ref.notes}
intervals={b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])}
return (-len(intervals),-len(rhythm),ref.start,ref.id)
return sequences.diverse(groups,rank,lambda r:r.treatment,policy.DEFAULT.theme_seed_limit)
def feature(reference, actual, *, treatment=None):
treatment=treatment or reference.treatment
required={'rhythmic_seed':('rhythm',),'contour_seed':('contour',),
'opening_fragment':('rhythm','pitches'),'characteristic_interval':('exact_intervals',),
'repeat_with_harmonic_adaptation':('rhythm','contour'),'answer':('contour',),
'extend':('rhythm','contour'),'fragment':('rhythm',),'callback':('rhythm','pitches')}[treatment]
original=reference.notes
origin=original[0].start
octave=12*round((actual[0].pitch.number()-original[0].pitch.number())/12)
return types.IdentifyingFeature(reference.id+':'+treatment,tuple(n.event_id for n in actual),
tuple((n.start-origin,n.end-n.start) for n in original),
tuple(b.pitch.number()-a.pitch.number() for a,b in zip(original,original[1:])),reference.id,required,
tuple(n.pitch.number()+octave for n in original))
def rhythm_windows(library,meter,length,context):
from generator.solo import drafts
key=('theme-rhythm',id(library),meter,length)
if key not in context.pools:
patterns={}
for identifier,pattern,total in drafts.catalog(library,meter):
if total<length:continue
for offset in sorted({F(),*(a for a,_ in pattern)}):
if offset+length>total or any(a<offset<b or a<offset+length<b for a,b in pattern):continue
selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
if selected and len(selected)<=16:patterns.setdefault(selected,identifier)
context.pools[key]=sequences.diverse(tuple(patterns.items()),
lambda x:(not .7*float(length)<=len(x[0])<=1.8*float(length),policy.stable_rank('rhythm-window',x[0])),
lambda x:(len(x[0]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8)
return context.pools[key]
def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operation='introduce', terminal=False):
length=finish-start; values={}
if home and operation=='repeat_with_harmonic_adaptation':
pattern=tuple((n.start,n.end) for n in home.notes if n.end<=length)
if pattern:values[pattern]='heard-theme'
prefix=tuple((n.start,n.end) for n in ref.notes)
if ref.treatment in ('rhythmic_seed','opening_fragment','callback') or operation in ('extend','fragment','repeat_with_harmonic_adaptation'):
duration=prefix[-1][1]
if duration==length:values[prefix]=ref.id
if duration<length:
for tail,label in rhythm_windows(rhythms,meter,length-duration,context):
values[(*prefix,*((a+duration,b+duration) for a,b in tail))]=ref.id+' + '+label
else:
values.update(rhythm_windows(rhythms,meter,length,context))
if home and operation=='answer':
for p,label in rhythm_windows(rhythms,meter,length,context):values.setdefault(p,label)
if terminal:
for original,label in tuple(values.items()):
for hold in (F(1,2),F(1),F(3,2),F(2)):
for release in (F(),F(1,2)):
arrival=length-hold-release
head=tuple((a,min(b,arrival)) for a,b in original if a<arrival)
if len(head)>=len(ref.notes):values[(*head,(arrival,length-release))]=label+f'; arrival {hold}, release {release}'
return sequences.diverse(tuple(values.items()),lambda x:(max(0,len(x[0])-1.5*float(length),float(length)-len(x[0])),policy.stable_rank(ref.id,x[0])),
lambda x:(len(x[0]),x[0][-1][1]-x[0][-1][0],x[0][0][0]),4)
def pitches(harmony,note,base,*,members=False,limit=6):
from generator.solo.leads import available
domain=available(harmony,note.start,note.end,members=members or note.end-note.start>=2)
return sequences.diverse(domain,lambda p:(abs(p.number()-base),p.number()),
lambda p:((p.number()-base>0)-(p.number()-base<0),p.number()//12),limit)
def relative_steps(notes,harmony):
result=[]
for a,b in zip(notes,notes[1:]):
scale=harmony.at(b.start).scale
try:step=types.scale_index(scale,b.pitch.number())-types.scale_index(scale,a.pitch.number())
except ValueError:
lo,hi=sorted((a.pitch.number(),b.pitch.number()))
intervening=range(lo+1,hi+1) if b.pitch.number()>a.pitch.number() else range(lo,hi)
count=sum(p%12 in scale.pitches for p in intervening)
step=count if b.pitch.number()>a.pitch.number() else -count
result.append(step)
return tuple(result)
def instantiate(templates,first,steps,harmony):
result=[dataclasses.replace(templates[0],pitch=first)]
for note,step in zip(templates[1:],steps):
span=harmony.at(note.start)
if not span or not span.scale:return ()
options=routes.advance(span.scale,result[-1].pitch.number(),step)
if not options:return ()
p=options[0]
if not 41<=p.number()<=76 or p.number()%12 not in types.permitted(harmony.choices,note.start,note.end):return ()
result.append(dataclasses.replace(note,pitch=p))
return tuple(result) if len(result)==len(templates) else ()
def prefix_paths(templates,ref,first,harmony,library):
count=len(ref.notes); original=ref.notes
if count==1:return ((dataclasses.replace(templates[0],pitch=first),),)
if ref.treatment in ('opening_fragment','characteristic_interval','callback'):
delta=first.number()-original[0].pitch.number()
if ref.treatment in ('opening_fragment','callback') and delta%12:return ()
result=[]
from generator.solo.leads import available
for template,n in zip(templates,original):
target=n.pitch.number()+delta
p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==target),None)
if p is None:return ()
result.append(dataclasses.replace(template,pitch=p))
return (tuple(result),)
paths=[]
end_template=templates[count-1]
source_motion=original[-1].pitch.number()-original[0].pitch.number()
for target in pitches(harmony,end_template,first.number()+source_motion,limit=4):
template=tuple(dataclasses.replace(n,pitch=first if i==0 else target) for i,n in enumerate(templates[:count]))
paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,(41,76),'seed '+ref.id))
valid=[p for p in paths if not references.feature_error(feature(ref,p),p)]
return sequences.diverse(valid,lambda ns:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(ns,ns[1:])),tuple(n.pitch for n in ns)),
lambda ns:sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:]))),4)
@dataclasses.dataclass(frozen=True)
class Unit:
notes: tuple[types.Note,...]
feature: types.IdentifyingFeature
reference: types.SourceReference
operation: str
anchor: types.Anchor
score: float
source: str
routes: tuple[types.RouteCandidate,...] = ()
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0):
# Position and the recent realization participate even when two paths share a last pitch.
if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',harmony.choices)
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,home,operation,terminal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
if key in context.results:
context.diagnostics['cache_hits']+=1;return context.results[key]
result=[]; count=len(ref.notes)
for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal):
if len(pattern)<count:continue
templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,tuple(s for n in ref.notes for s in n.sources),
'theme',event_id=f'{ref.id}:{start}:{i}') for i,(a,b) in enumerate(pattern))
base=incoming.last.pitch.number() if incoming.last else ref.notes[0].pitch.number()
firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3)
for first in firsts:
proposed=[]
if home and operation=='repeat_with_harmonic_adaptation' and len(templates)==len(home.notes):
heard=instantiate(templates,first,home.degree_steps,harmony)
if heard:proposed.append((heard,()))
for prefix in prefix_paths(templates,ref,first,harmony,library):
if len(prefix)==len(templates):
proposed.append((prefix,()));continue
base=prefix[-1].pitch.number()
for target in pitches(harmony,templates[-1],base,members=True,limit=4 if batch else 2):
tail=(prefix[-1],*templates[count:])
tail=(*tail[:-1],dataclasses.replace(tail[-1],pitch=target))
for route in routes.candidates(tail,{tail[-1].event_id},harmony,library,(41,76),operation+' '+ref.id):
proposed.append(((*prefix[:-1],*route.notes),(route,)))
for actual,connections in proposed:
core=actual[:count]; contract=feature(ref,core,treatment=operation if operation!='introduce' else None)
if references.feature_error(contract,actual):continue
last=actual[-1]
if last.pitch.number()%12 not in harmony.members(last.start,last.end):continue
checked=harmony.evaluate(actual,until=finish,complete=True)
if not checked.valid:continue
density=len(actual)*4/float(finish-start)
entry=max(0,abs(first.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0.
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(actual,actual[1:]))/max(1,len(actual)-1)
# A repeat preserves the full heard idea. Answers and extensions
# preserve their declared fragment, not every event of the old ending.
development=references.relationship(actual,home) if home and operation=='repeat_with_harmonic_adaptation' else 0.
score=2*checked.exposure+entry+leaps+development+.35*max(0,4-density,density-6)
anchor=types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76),
last.end-last.start,'terminal' if terminal else 'intermediate',harmony.at(last.start).chord,hold_until=last.end)
result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections))
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
selected=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
lambda u:(u.notes[0].pitch.number()//12,u.notes[-1].pitch.number()%12,tuple(n.end-n.start for n in u.notes[:count])),8)
context.diagnostics['generated_candidates']+=len(selected)
context.results[key]=selected
return selected
def home_reference(home,operation):
notes=home.notes[:min(4,len(home.notes))]
# A short characteristic beginning leaves room for a newly composed answer.
while len(notes)>3 and notes[-1].end>home.duration/2:notes=notes[:-1]
return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,
'rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','extend','fragment') else 'contour_seed')
def make_home(unit,harmony,start=None,end=None):
from generator.solo.realization import make_identity
start=unit.notes[0].start if start is None else start
identity=policy.stable_rank('solo-theme',(unit.reference.id,unit.notes))[:16]
home=make_identity('solo_theme_'+identity,unit.reference.id,unit.notes,start,unit.notes[-1].end if end is None else end,'introduce',harmony)
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony))
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False):
from generator.solo import leads,realization
if source.end-source.start<2:
return short_fragment(source,incoming,part,harmony,tempo,seed,final)
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
unit_length=2*bar
head=source_notes(analysis)
seed_key=('source-seeds',analysis,bar,seed)
if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed)
roots=context.pools[seed_key]
states=[(0.,(),incoming.home,incoming)]
cursor=source.start
while cursor<source.end:
finish=min(cursor+unit_length,source.end)
next_states=[]
for cost,units,home,state in states:
introducing=home is None
if introducing:
requests=[(r,'introduce') for r in roots]
else:
operations=('repeat_with_harmonic_adaptation',) if cursor==source.start and source.start<=4*bar else ('repeat_with_harmonic_adaptation','answer','extend')
requests=[(home_reference(home,op),op) for op in operations]
used=incoming.callbacks+sum(u.operation=='callback' for u in units)
if cursor>=8*bar and cursor==source.start and used<2:
requests.extend((dataclasses.replace(r,treatment='callback'),'callback') for r in roots[:2] if r.end-r.start<=bar)
for ref,operation in requests:
if ref.notes[-1].end>finish-cursor:
reduced=tuple(n for n in ref.notes if n.end<=finish-cursor)
if len(reduced)<2:continue
ref=dataclasses.replace(ref,notes=reduced,treatment='contour_seed')
operation='answer'
options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation,
terminal=final and finish==source.end,batch=batch)
for unit in options:
if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']:
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
actual_home=home or make_home(unit,harmony,cursor,finish)
following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref)
next_states.append((cost+unit.score,(*units,unit),actual_home,following))
states=sequences.diverse(next_states,lambda v:(v[0],policy.stable_rank(seed,tuple(u.notes for u in v[1]))),
lambda v:(v[2].id,v[1][-1].notes[-1].pitch.number()),2 if incoming.home and finish<source.end else 8)
if not states:return ()
cursor=finish
result=[]
for cost,units,home,state in states:
plain=tuple(n for u in units for n in u.notes)
if similarity.near_copy(plain,head,source.start,source.end)['near_copy']:
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
features=tuple(u.feature for u in units)
anchors=tuple(u.anchor for u in units)
options=[(plain,(),())]
if stage=='elaborated':
protected=(*features,types.IdentifyingFeature('destinations',tuple(n.event_id for n in plain if any(n.event_id+':arrival'==a.id for a in anchors)),(),(),'',()))
for actual,edit,route in leads.elaborate(plain,protected,harmony,library,rhythms,meter,source,sustain_entry=True):
options.append((actual,(edit,),(route,)))
for notes,edits,selected_routes in options:
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,leads.signature(notes))[:12]
transforms=tuple(types.Transformation(identifier+f':{i}',u.reference.id,home.id,u.operation,u.notes[0].start,u.notes[-1].end,
tuple(n.event_id for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),tuple((n.start-u.notes[0].start,n.end-u.notes[0].start) for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),preserves=(),source=u.reference.id+'; rhythm '+u.source) for i,u in enumerate(units))
source_ref=state.source_reference
draft=types.MelodicDraft(identifier,source.start,source.end,notes,transforms,
tuple(a.id.removesuffix(':arrival') for a in anchors),(),anchors,home,'introduce' if not incoming.home else 'develop',
features=features,reference=source_ref.id,reference_motif=home,source_phrase=source,source_reference=source_ref,
source_links=tuple((n.event_id,n.sources) for n in notes))
# One shared musical context crosses computational/source boundaries.
activity=[];cursor=source.start
for n in notes:
if n.start>cursor:activity.append(types.ActivitySpan(cursor,n.start,'space',F(),F()))
activity.append(types.ActivitySpan(n.start,n.end,'active',F(5,4),F()))
cursor=n.end
if cursor<source.end:activity.append(types.ActivitySpan(cursor,source.end,'space',F(),F()))
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,(41,76),F(5,4),
source.end-notes[-1].end,tuple(activity),(), 'sustain' if final else 'connected','closed' if final else 'open',seed,
source.id,source.start,source.end,(anchors,))
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
if phrase is None:continue
density=len(notes)*4/float(source.end-source.start)
score=cost/len(units)+.35*(max(0,4-density,density-6)-max(0,4-len(plain)*4/float(source.end-source.start),len(plain)*4/float(source.end-source.start)-6))
score+=.05*phrase.state.travel/max(1,len(notes))/2000
current=dataclasses.replace(phrase.state,home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units))
# Store the actual introduced sound, including any legal elaboration.
if not incoming.home:
introduced=tuple(n for n in notes if n.start<units[0].notes[-1].end)
heard=dataclasses.replace(home,notes=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced),
degree_steps=relative_steps(introduced,harmony))
current=dataclasses.replace(current,home=heard,themes=(heard,))
source_control=leads.shift(source.notes, next((o for o in (0,1,-1,2,-2) if all(41<=n.pitch.number()+12*o<=76 for n in source.notes)),0))
original=dataclasses.replace(draft,id=identifier+':source',notes=source_control,features=(),anchors=(),structural=())
refined=dataclasses.replace(draft,id=identifier+':plain',notes=plain)
result.append(dataclasses.replace(phrase,score=score,state=current,initial=original,refined=refined,routes=(*tuple(r for u in units for r in u.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in notes) for n in r.notes)),*selected_routes)))
return sequences.diverse(result,lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
lambda p:(p.state.home.id,p.state.last.pitch.number(),sum(e.kind=='complete_route' for e in p.edits)),8)
+28
View File
@@ -57,6 +57,16 @@ class Motif:
def contour(self) -> tuple[int, ...]: def contour(self) -> tuple[int, ...]:
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:])) return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
@dataclasses.dataclass(frozen=True)
class SourcePhrase:
id: str
start: fractions.Fraction
end: fractions.Fraction
notes: tuple[Note, ...]
rests: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
family: str
pickup: tuple[Note, ...] = ()
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class TargetEvent: class TargetEvent:
earliest: fractions.Fraction earliest: fractions.Fraction
@@ -125,6 +135,7 @@ class RealizedMotif:
treatment: str treatment: str
chord_degrees: tuple[tuple[int, ...], ...] = () chord_degrees: tuple[tuple[int, ...], ...] = ()
source_start: fractions.Fraction = fractions.Fraction() source_start: fractions.Fraction = fractions.Fraction()
degree_steps: tuple[int, ...] = ()
def contour(self): def contour(self):
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:])) return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
@@ -146,6 +157,8 @@ class ContinuationState:
selected_anchors: tuple[Anchor, ...] = () selected_anchors: tuple[Anchor, ...] = ()
ending: str = '' ending: str = ''
lead_identity: LeadIdentity | None = None lead_identity: LeadIdentity | None = None
source_reference: SourceReference | None = None
callbacks: int = 0
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Transformation: class Transformation:
@@ -177,6 +190,17 @@ class IdentifyingFeature:
rhythm: tuple[tuple[fractions.Fraction, fractions.Fraction], ...] rhythm: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
intervals: tuple[int, ...] intervals: tuple[int, ...]
source: str source: str
preserves: tuple[str, ...] = ('rhythm', 'intervals')
pitches: tuple[int, ...] = ()
@dataclasses.dataclass(frozen=True)
class SourceReference:
id: str
source_id: str
start: fractions.Fraction
end: fractions.Fraction
notes: tuple[Note, ...]
treatment: str
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class RouteCandidate: class RouteCandidate:
@@ -205,6 +229,10 @@ class MelodicDraft:
reference_motif: Motif | RealizedMotif | None = None reference_motif: Motif | RealizedMotif | None = None
lead_identity: LeadIdentity | None = None lead_identity: LeadIdentity | None = None
correspondence: tuple[tuple[str, str], ...] = () correspondence: tuple[tuple[str, str], ...] = ()
source_phrase: SourcePhrase | None = None
octave_transfer: int = 0
source_links: tuple[tuple[str, tuple[str, ...]], ...] = ()
source_reference: SourceReference | None = None
def rests(self): def rests(self):
cursor, result = self.start, [] cursor, result = self.start, []
for note in self.notes: for note in self.notes:
+2 -2
View File
@@ -116,7 +116,7 @@ class SoloTests(unittest.TestCase):
source = song(count=6, chords=('CM7',)) source = song(count=6, chords=('CM7',))
result = compose(source) result = compose(source)
self.assertLessEqual(len({p.plan.motif.id for p in result.phrases}), 3) self.assertLessEqual(len({p.plan.motif.id for p in result.phrases}), 3)
self.assertEqual(result.phrases[0].plan.motif.id, result.phrases[1].plan.motif.id) self.assertTrue(all(p.draft.source_phrase for p in result.phrases))
self.assertFalse(any(d.treatment.startswith('quote') for p in result.phrases for d in p.decisions)) self.assertFalse(any(d.treatment.startswith('quote') for p in result.phrases for d in p.decisions))
original = result.phrases[0].plan original = result.phrases[0].plan
explicit = dataclasses.replace(original, treatment='exact_quote', target=None, anchor_options=(), gestures=(), passage_id='', passage_start=None, passage_end=None) explicit = dataclasses.replace(original, treatment='exact_quote', target=None, anchor_options=(), gestures=(), passage_id='', passage_start=None, passage_end=None)
@@ -154,7 +154,7 @@ class SoloTests(unittest.TestCase):
self.assertEqual(result.phrases[-1].plan.end, fractions.Fraction(43, 2)) self.assertEqual(result.phrases[-1].plan.end, fractions.Fraction(43, 2))
def test_search_exhaustion_does_not_claim_impossibility(self): def test_search_exhaustion_does_not_claim_impossibility(self):
source = song() source = song()
with unittest.mock.patch('generator.solo.realization.draft_pitch_paths', return_value=((), 'no route within the draft budget')), self.assertRaisesRegex(generator.solo.realization.SearchExhausted, 'not proof'): with unittest.mock.patch('generator.solo.themes.phrase_candidates', return_value=()), self.assertRaisesRegex(generator.solo.realization.SearchExhausted, 'not proof'):
compose(source) compose(source)
def test_time_aware_placement_does_not_penalize_absolute_frets(self): def test_time_aware_placement_does_not_penalize_absolute_frets(self):
p = generator.solo.types.Pitch('c', fractions.Fraction(), 4) p = generator.solo.types.Pitch('c', fractions.Fraction(), 4)
+5 -4
View File
@@ -187,12 +187,13 @@ class DraftCompositionTests(unittest.TestCase):
self.assertTrue(generator.solo.placement.place(draft.notes, self.solo.tempo, previous)) self.assertTrue(generator.solo.placement.place(draft.notes, self.solo.tempo, previous))
self.assertTrue(draft.transformations) self.assertTrue(draft.transformations)
previous = phrase.state.last previous = phrase.state.last
def test_home_survives_several_developments_and_phrase_boundary(self): def test_source_family_survives_phrase_variations(self):
first, second = self.solo.phrases first, second = self.solo.phrases
self.assertEqual(first.state.home.id, second.state.home.id) 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)) for phrase in self.solo.phrases:
self.assertNotEqual(second.state.development.id, second.state.home.id) self.assertTrue(all(t.home == phrase.state.home.id for t in phrase.draft.transformations))
self.assertEqual(second.state.selected_anchors, first.state.selected_anchors) self.assertTrue(phrase.state.home.chord_degrees)
self.assertTrue(phrase.draft.source_links)
def test_groups_cross_barlines_without_reference_timing_reset(self): def test_groups_cross_barlines_without_reference_timing_reset(self):
# Exercise an explicitly spanning construction; do not require a changed # Exercise an explicitly spanning construction; do not require a changed
# search objective to pick the same old winner merely to test timing. # search objective to pick the same old winner merely to test timing.
+129 -138
View File
@@ -1,154 +1,145 @@
"""Construction and permission contracts; listening remains the taste test.""" """Production source variations, strict scale permission and paired interpretation."""
import copy
import dataclasses import dataclasses
from fractions import Fraction as F
import pathlib import pathlib
import unittest import unittest
from fractions import Fraction as F
from generator.solo import analysis, composer, drafts, harmonic, leads, references, realization, scoring, types from generator.solo import analysis, composer, drafts, harmonic, leads, placement, realization, scoring, types
import tests.test_solo as solo_fixture import generator.fretboard
import generator.model
import tests.test_solo as fixture
import tests.test_solo_development as notes_fixture
import tests.test_solo_passages as passage_fixture import tests.test_solo_passages as passage_fixture
import tests.test_solo_development as fixture
import tests.rhythm_fixtures
import tests.scale_fixtures import tests.scale_fixtures
import tests.rhythm_fixtures
import tests.solo_fixtures import tests.solo_fixtures
class ChromaticApproachTests(unittest.TestCase): class SourceVariationTests(unittest.TestCase):
def setUp(self):
self.harmony = harmonic.Harmony(passage_fixture.choices(('C7', 'FM7')))
self.approach = dataclasses.replace(fixture.note(F(7, 2), 4, 'g', -1), event_id='approach')
self.target = dataclasses.replace(fixture.note(4, 6, 'f'), event_id='resolution')
self.treatment = types.HarmonicTreatment('test', 'chromatic_approach', 66, F(7, 2), F(4), 65, F(4), F(), 'approach', 'resolution')
def test_approach_resolves_across_chord_boundary_without_remapping_scale(self):
self.assertFalse(self.harmony.evaluate((self.approach, self.target), complete=True).valid)
result = self.harmony.evaluate((self.approach, self.target), (self.treatment,), complete=True)
self.assertTrue(result.valid, result.error)
self.assertFalse(result.pending)
report = self.harmony.describe((self.approach, self.target), (self.treatment,))
self.assertEqual((report[0]['function'], report[0]['motion']), ('outside_collection', 'chromatic_approach'))
def test_pending_approach_survives_computational_boundary(self):
partial = self.harmony.evaluate((self.approach,), (self.treatment,), until=F(4))
self.assertTrue(partial.valid)
self.assertEqual(partial.pending, (self.treatment,))
self.assertFalse(self.harmony.evaluate((self.approach,), (self.treatment,), until=F(5)).valid)
def test_tie_partition_does_not_change_approach_or_sustain(self):
split = (dataclasses.replace(self.approach, end=F(15, 4)), dataclasses.replace(self.approach, start=F(15, 4), continuation=True, event_id='tie'), dataclasses.replace(self.target, end=F(5)), dataclasses.replace(self.target, start=F(5), continuation=True, event_id='target_tie'))
whole = (self.approach, self.target)
self.assertEqual(self.harmony.evaluate(whole, (self.treatment,), complete=True), self.harmony.evaluate(split, (self.treatment,), complete=True))
self.assertEqual(self.harmony.describe(whole, (self.treatment,)), self.harmony.describe(split, (self.treatment,)))
self.assertEqual(len(types.sounding_notes(split)), 2)
def test_labels_cannot_excuse_arbitrary_notes(self):
cases = [
(dataclasses.replace(self.approach, start=F(3)), self.target, self.treatment),
(self.approach, dataclasses.replace(self.target, start=F(9, 2)), self.treatment),
(self.approach, dataclasses.replace(self.target, pitch=fixture.note(0, 1, 'e').pitch), self.treatment),
(self.approach, self.target, dataclasses.replace(self.treatment, event='unrelated')),
(dataclasses.replace(self.approach, role='quote'), self.target, self.treatment),
(self.approach, self.target, dataclasses.replace(self.treatment, deadline=F(5))),
]
for approach, target, treatment in cases:
with self.subTest(approach=approach, target=target, treatment=treatment):
self.assertFalse(self.harmony.evaluate((approach, target), (treatment,), complete=True).valid)
def test_resolution_must_be_chord_member_and_valid_through_its_hold(self):
nonmember = dataclasses.replace(self.target, pitch=fixture.note(4, 6, 'g').pitch)
treatment = dataclasses.replace(self.treatment, resolution=67)
self.assertFalse(self.harmony.evaluate((self.approach, nonmember), (treatment,), complete=True).valid)
long_approach = dataclasses.replace(self.approach, start=F(2))
self.assertFalse(self.harmony.evaluate((long_approach, self.target), (dataclasses.replace(self.treatment, start=F(2)),), complete=True).valid)
def test_approach_spelling_avoids_unnecessary_double_accidentals(self):
target = fixture.note(0, 1, 'a', -1).pitch
self.assertEqual(leads.approach_pitch(target, 69).label(), 'A4')
target = fixture.note(0, 1, 'e').pitch
self.assertEqual(leads.approach_pitch(target, 63).label(), 'D#4')
def test_chromatic_spacing_carries_across_passage_boundary(self):
notes = (dataclasses.replace(self.approach, pitch=fixture.note(0, 1, 'g').pitch), self.target)
plan = dataclasses.replace(fixture.plan(), end=F(8), release=F(2), target=None, passages=())
group = types.Transformation('group', '', '', 'answer', F(), F(8), tuple(n.event_id for n in notes), tuple((n.start, n.end) for n in notes), preserves=())
draft = types.MelodicDraft('spacing', F(), F(8), notes, (group,), ('resolution',), (), (), None, 'answer')
empty = types.ContinuationState('previous')
phrase = realization.checked_phrase(plan, draft, notes, (), empty, self.harmony, F(120))
self.assertIsNotNone(phrase)
self.assertTrue(leads.chromatic(phrase, empty, self.harmony, F(120)).edits)
recent = dataclasses.replace(empty, treatments=(dataclasses.replace(self.treatment, start=F(-7, 2)),))
self.assertFalse(leads.chromatic(phrase, recent, self.harmony, F(120)).edits)
class CompleteLeadTests(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.references = references.load_library(pathlib.Path('data/lead_phrases')) cls.document=fixture.song(count=8,chords=('CM7',))
cls.source = solo_fixture.song(count=8, chords=('Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7'))
cls.context=realization.SearchContext() cls.context=realization.SearchContext()
cls.result = cls.compose(cls.context) cls.solo=cls.compose(cls.document,cls.context)
@classmethod @classmethod
def compose(cls, context, stage='elaborated'): def compose(cls,document,context=None,end=32):
return composer.compose(analysis.analyze(cls.source), cls.source['staves'][0]['measures'], 'acoustic', F(32), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library(), references=cls.references, context=context, stage=stage) return composer.compose(analysis.analyze(document),document['staves'][0]['measures'],'acoustic',F(end),
def test_complete_tune_precedes_decorations_and_contains_a_return(self): scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),context=context,mode='paraphrase')
for phrase in self.result.phrases: def test_production_is_traceable_to_complete_source_phrases(self):
self.assertTrue(phrase.draft.lead_identity) self.assertTrue(self.solo.phrases)
self.assertTrue(phrase.draft.correspondence) for p in self.solo.phrases:
self.assertTrue(drafts.contract_valid(phrase.initial, phrase.initial.notes)[0]) self.assertIsNotNone(p.draft.source_phrase)
self.assertTrue(drafts.contract_valid(phrase.initial, realization.emitted_notes(phrase))[0]) self.assertTrue(p.draft.source_links)
source = {n.event_id: n for n in phrase.initial.notes} self.assertTrue(p.edits)
for a, b in phrase.draft.correspondence: self.assertGreater(len(p.draft.source_phrase.notes),8)
self.assertEqual(source[a].end - source[a].start, source[b].end - source[b].start) self.assertTrue(drafts.contract_valid(p.draft,composer.phrase_notes(p))[0])
self.assertNotIn('diminish', [t.operation for t in phrase.draft.transformations]) self.assertTrue(all(n.sources for n in p.draft.notes))
for route in phrase.routes: def test_same_input_and_cache_preserve_complete_music(self):
run = peak = 0 other=self.compose(self.document,self.context)
end = None self.assertEqual(self.solo.notes(),other.notes())
for note in route.notes:
eighth = note.end-note.start == F(1,2)
run = run+1 if eighth and end == note.start else int(eighth)
peak = max(peak, run)
end = note.end
self.assertGreaterEqual(peak, 4)
composer.validate(self.result)
self.assertTrue(all(1 <= n.position.fret <= 12 for n in self.result.notes()))
def test_same_inputs_and_cache_preserve_music(self):
second = self.compose(self.context)
self.assertEqual(self.result.notes(), second.notes())
self.assertGreater(self.context.diagnostics['cache_hits'],0) self.assertGreater(self.context.diagnostics['cache_hits'],0)
def test_lead_evaluation_is_independent_of_computational_partitions(self): def test_source_change_drives_music_with_fixed_harmony(self):
phrase = self.result.phrases[0] changed=copy.deepcopy(self.document)
whole = phrase.initial.notes for m in changed['staves'][0]['measures']:
split = [] for n in m['voices'][0]['notes']:
for note in whole: n['pitches'][0]['letter']='g' if n['pitches'][0]['letter']=='c' else n['pitches'][0]['letter']
cuts = sorted({note.start,note.end} | {F(i,2) for i in range(64) if note.start < F(i,2) < note.end}) other=self.compose(changed)
split.extend(dataclasses.replace(note,start=a,end=b,continuation=i>0) for i,(a,b) in enumerate(zip(cuts,cuts[1:]))) self.assertNotEqual(leads.signature(self.solo.notes()),leads.signature(other.notes()))
harmony = harmonic.Harmony(self.result.choices) self.assertNotEqual(self.solo.phrases[0].initial.notes[0].pitch.number()%12,other.phrases[0].initial.notes[0].pitch.number()%12)
self.assertEqual(harmony.describe(whole),harmony.describe(split)) def test_source_ties_and_rests_are_retained_before_variation(self):
self.assertEqual(scoring.features(phrase.plan,whole,self.result.choices),scoring.features(phrase.plan,split,self.result.choices)) melody=(notes_fixture.note(0,5,'c'),notes_fixture.note(6,9,'e'),notes_fixture.note(9,12,'g'),notes_fixture.note(12,16,'c'))
self.assertTrue(drafts.contract_valid(phrase.initial,split)[0]) melody=tuple(dataclasses.replace(n,sources=(f's{i}',)) for i,n in enumerate(melody))
def test_activity_and_occupancy_are_ranges(self): source=analysis.source_phrases(melody,F(),F(16),F(4))
plan = dataclasses.replace(fixture.plan(), start=F(), end=F(4), release=F(), treatment='lead', passages=()) self.assertEqual(tuple(n for p in source for n in p.notes)[0].end,5)
self.assertTrue(any((F(5),F(6)) in p.rests for p in source))
def test_strict_map_rejects_chromatic_labels(self):
harmony=harmonic.Harmony(passage_fixture.choices(('C7',)))
approach=dataclasses.replace(notes_fixture.note(F(5,2),3,'f',1),event_id='approach')
target=dataclasses.replace(notes_fixture.note(3,4,'g'),event_id='target')
contract=types.HarmonicTreatment('removed','chromatic_approach',66,F(5,2),F(3),67,F(3),F(),'approach','target')
self.assertFalse(harmony.evaluate((approach,target),complete=True).valid)
self.assertFalse(harmony.evaluate((approach,target),(contract,),complete=True).valid)
def test_mapped_accidentals_and_half_steps_remain_valid(self):
scale=tests.scale_fixtures.library().scale('harmonic_minor',1,'D')
choices=passage_fixture.choices(('A7','Dm7'),duration=2,scale=scale)
harmony=harmonic.Harmony(choices)
notes=(notes_fixture.note(0,F(3,2),'e'),notes_fixture.note(F(3,2),2,'c',1),notes_fixture.note(2,4,'d'))
self.assertTrue(harmony.evaluate(notes,complete=True).valid)
def test_partition_does_not_change_sounding_evaluation(self):
phrase=self.solo.phrases[0]
whole=phrase.draft.notes;split=[]
for n in whole:
middle=(n.start+n.end)/2
split.extend((dataclasses.replace(n,end=middle),dataclasses.replace(n,start=middle,continuation=True)))
harmony=harmonic.Harmony(self.solo.choices)
self.assertEqual(harmony.evaluate(whole,complete=True),harmony.evaluate(split,complete=True))
self.assertEqual(scoring.features(phrase.plan,whole,self.solo.choices),scoring.features(phrase.plan,split,self.solo.choices))
self.assertTrue(drafts.contract_valid(phrase.draft,split)[0])
def test_low_register_and_tied_placement(self):
low=dataclasses.replace(notes_fixture.note(0,2,'a'),pitch=types.Pitch('a',F(),2))
following=dataclasses.replace(low,start=2,end=4,continuation=True)
placed=placement.place((low,following),F(120))
self.assertEqual(placed[1][0].position.string_number,6)
self.assertEqual(placed[1][0].position,placed[1][1].position)
self.assertEqual({p.string_number for number in range(41,77) for p in placement.positions(number)},set(range(1,7)))
def test_pair_metric_detects_octave_copy(self):
shifted=dataclasses.replace(self.solo,phrases=tuple(dataclasses.replace(p,decisions=tuple(dataclasses.replace(d,notes=leads.shift(d.notes,1)) for d in p.decisions)) for p in self.solo.phrases))
self.assertEqual(leads.similarity(self.solo,shifted),1)
callback=dataclasses.replace(shifted,phrases=tuple(dataclasses.replace(p,plan=dataclasses.replace(p.plan,relationship='callback')) for p in shifted.phrases))
self.assertEqual(leads.similarity(self.solo,callback),0)
def test_later_arrivals_compare_complete_routes_with_different_holds(self):
notes=tuple(dataclasses.replace(notes_fixture.note(a,b,k),event_id=str(a),sources=(str(a),))
for a,b,k in ((0,1,'g'),(1,2,'e'),(2,8,'c')))
source=types.SourcePhrase('ending',F(),F(8),notes,(),'family')
harmony=harmonic.Harmony(passage_fixture.choices(('C7','C7')))
options=leads.elaborate(notes,(),harmony,tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',source)
self.assertEqual({r.destination.end-r.destination.start for _,_,r in options},{F(1,2),F(1),F(3,2),F(2)})
for actual,_,route in options:
self.assertTrue(harmony.evaluate(actual,complete=True).valid)
self.assertEqual(actual[-1],route.destination)
self.assertEqual((actual[0].start,actual[-1].end),(0,8))
def test_repeated_source_cadence_is_not_accidental_monotony(self):
first=self.solo.phrases[0]
changed=dataclasses.replace(first,draft=dataclasses.replace(first.draft,
source_phrase=dataclasses.replace(first.draft.source_phrase,family='unrelated')))
self.assertAlmostEqual(leads.transition(first,changed)-leads.transition(first,first),.15)
def test_space_and_vocal_return_are_preserved(self):
silent=self.compose(fixture.song(count=8,chords=()))
self.assertFalse(silent.notes())
shortened=self.compose(self.document,end=F(29))
self.assertTrue(all(n.end<=29 for n in shortened.notes()))
composer.validate(shortened)
def test_pair_selection_produces_valid_distinct_source_variations(self):
a=analysis.analyze(self.document)
pair=composer.compose_pair(a,{part:(self.document['staves'][0]['measures'],F(32),None) for part in ('acoustic','electric')},
scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),mode='paraphrase')
self.assertLess(leads.similarity(*pair),1)
self.assertEqual(pair[0].diagnostics['pairs_compared'],64)
for solo in pair:
composer.validate(solo)
for phrase in solo.phrases:
heard={n.event_id:n for n in composer.phrase_notes(phrase)}
for route in phrase.routes:
self.assertEqual(route.notes[-1].pitch,route.destination.pitch)
for note in route.notes:
self.assertEqual((heard[note.event_id].pitch,heard[note.event_id].start,heard[note.event_id].end),(note.pitch,note.start,note.end))
def test_pickup_is_brought_inside_window_without_touching_previous_section(self):
melody=(notes_fixture.note(0,5,'g'),notes_fixture.note(5,6,'a'),notes_fixture.note(6,8,'g'))
melody=tuple(dataclasses.replace(n,sources=(f's{i}',),event_id=f's{i}') for i,n in enumerate(melody))
pickup=tuple(dataclasses.replace(notes_fixture.note(i-3,i-2,name),sources=(f'p{i}',),event_id=f'p{i}') for i,name in enumerate(('c','d','e')))
source=types.SourcePhrase('pickup',F(),F(8),melody,(),'family',pickup)
harmony=harmonic.Harmony(passage_fixture.choices(('C7','C7')))
options=[notes for notes,edits in leads.variations(melody,source,harmony) if edits[0].kind=='pickup_entrance']
self.assertTrue(options)
self.assertEqual(options[0][0].start,0)
self.assertEqual(options[0][3].start,3)
self.assertEqual(options[0][3].end,5)
self.assertTrue(harmony.evaluate(options[0],complete=True).valid)
def test_occupancy_is_not_an_objective_and_active_silence_is_invalid(self):
plan=dataclasses.replace(notes_fixture.plan(),end=F(4),release=F(),passages=(),treatment='source_variation')
choices=passage_fixture.choices(('C7',)) choices=passage_fixture.choices(('C7',))
for count in (4, 5, 6): for end in (F(1),F(4)):
notes = tuple(fixture.note(F(4*i, count), F(4*(i+1), count), 'e') for i in range(count)) self.assertEqual(scoring.features(plan,(notes_fixture.note(0,end,'e'),),choices)['occupancy'],0)
values = scoring.features(plan, notes, choices)
self.assertEqual(values['activity'], 0)
self.assertEqual(values['occupancy'], 0)
self.assertFalse(scoring.feasible(plan,(),choices,F(4))) self.assertFalse(scoring.feasible(plan,(),choices,F(4)))
def test_new_identity_is_a_joint_rhythm_and_pitch_object(self):
identities = leads.identities(self.references, types.ContinuationState(''))
self.assertEqual(len(identities), 12)
self.assertGreater(len({i.durations for i in identities}), 1)
self.assertGreater(len({i.degrees for i in identities}), 1)
for identity in identities:
self.assertEqual(len(identity.degrees), len(identity.durations))
self.assertEqual(sum(identity.durations), 4)
def test_invented_material_can_be_replaced_but_committed_hook_is_preserved(self):
phrase = self.result.phrases[0]
changed = tuple(n for n in phrase.initial.notes if n.event_id != phrase.draft.correspondence[0][1])
self.assertFalse(drafts.contract_valid(phrase.initial, changed)[0])
def test_missing_harmony_remains_space_with_lead_generation_enabled(self):
document = solo_fixture.song(count=4, chords=())
solo = composer.compose(analysis.analyze(document), document['staves'][0]['measures'], 'acoustic', F(16), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library(), references=self.references)
self.assertFalse(solo.notes())
composer.validate(solo)
def test_vocal_pickup_cutoff_does_not_compress_a_full_ending(self):
solo = composer.compose(analysis.analyze(self.source), self.source['staves'][0]['measures'], 'electric', F(29), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library(), references=self.references)
composer.validate(solo)
self.assertTrue(all(n.end <= 29 and n.end-n.start >= F(1,2) for n in solo.notes()))
tail = [n for n in solo.notes() if n.start >= 28]
self.assertEqual([(n.start,n.end) for n in tail], [(28,29)])
if __name__ == '__main__': if __name__=='__main__':unittest.main()
unittest.main()
+7 -16
View File
@@ -115,24 +115,15 @@ class HarmonicPassageTests(unittest.TestCase):
self.assertEqual(generator.solo.types.scale_index(scale, 65) - generator.solo.types.scale_index(scale, 64), 1) self.assertEqual(generator.solo.types.scale_index(scale, 65) - generator.solo.types.scale_index(scale, 64), 1)
class PassageStateTests(unittest.TestCase): class PassageStateTests(unittest.TestCase):
def test_eight_bar_cadence_carries_realized_identity_and_anchor_line(self): def test_source_phrases_carry_identity_and_completed_anchors(self):
import tests.test_solo import tests.test_solo
solo = tests.test_solo.compose(tests.test_solo.song(count=8, chords=('Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7'))) solo = tests.test_solo.compose(tests.test_solo.song(count=8, chords=('Gm7', 'C7', 'FM7', 'BbM7', 'Em7b5', 'A7', 'Dm7', 'Dm7')))
self.assertEqual(len(solo.passages), 1) self.assertEqual((solo.passages[0].start, solo.passages[-1].end), (0, 32))
self.assertEqual((solo.passages[0].start, solo.passages[0].end), (0, 32)) for phrase in solo.phrases:
self.assertEqual([(p.plan.start, p.plan.end) for p in solo.phrases], [(0, 16), (16, 32)]) self.assertTrue(phrase.draft.source_phrase)
first, second = solo.phrases self.assertTrue(phrase.state.home.chord_degrees)
self.assertEqual(first.plan.ending, 'connected') self.assertTrue(all(generator.solo.harmonic.anchor_note(a, phrase.state.notes) for a in phrase.anchors))
self.assertEqual(first.state.motifs, second.state.motifs[:len(first.state.motifs)]) self.assertTrue(generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=True, closure='closed')[0])
self.assertEqual(first.anchors, second.anchors)
self.assertTrue(any(a.role == 'intermediate' for a in second.anchors))
self.assertTrue(all(generator.solo.harmonic.anchor_note(a, second.state.notes) for a in second.anchors))
heard = {m.id: m for m in second.state.motifs}
self.assertGreaterEqual(len(heard), 5)
for motif in list(heard.values())[1:]:
self.assertIn(motif.parent, heard)
self.assertTrue(motif.chord_degrees)
self.assertTrue(generator.solo.harmonic.anchors_valid(second.anchors, solo.notes(), solo.end, complete=True, closure='closed')[0])
def test_development_references_realization_instead_of_source_template(self): def test_development_references_realization_instead_of_source_template(self):
plan = tests.test_solo_development.plan() plan = tests.test_solo_development.plan()
harmony = generator.solo.harmonic.Harmony(choices(('C7',))) harmony = generator.solo.harmonic.Harmony(choices(('C7',)))
+132
View File
@@ -0,0 +1,132 @@
"""Source influence and realized-theme continuity are different contracts."""
import dataclasses
from fractions import Fraction as F
import unittest
from generator.solo import analysis, composer, drafts, harmonic, leads, references, realization, similarity, themes, types
import tests.test_solo as fixture
import tests.test_solo_development as notes_fixture
import tests.test_solo_passages as harmony_fixture
import tests.scale_fixtures
import tests.solo_fixtures
import tests.rhythm_fixtures
def melody(values):
return tuple(dataclasses.replace(notes_fixture.note(a,b,p),event_id=str(i),sources=(str(i),)) for i,(a,b,p) in enumerate(values))
class ThemeContracts(unittest.TestCase):
def test_selected_rhythm_does_not_also_require_source_pitches(self):
source=melody(((0,1,'c'),(1,2,'d'),(2,3,'e')))
ref=types.SourceReference('source','phrase',F(),F(3),source,'rhythmic_seed')
actual=melody(((8,9,'g'),(9,10,'e'),(10,11,'c')))
self.assertEqual(references.feature_error(themes.feature(ref,actual),actual),0)
contour=dataclasses.replace(ref,treatment='contour_seed')
self.assertGreater(references.feature_error(themes.feature(contour,actual),actual),0)
def test_a_single_event_is_not_evidence_of_a_multinote_feature(self):
source=melody(((0,1,'c'),(1,2,'d'),(2,3,'e')))
ref=types.SourceReference('source','phrase',F(),F(3),source,'rhythmic_seed')
contract=themes.feature(ref,source)
self.assertEqual(references.feature_error(contract,source[:1]),1)
self.assertEqual(references.feature_error(themes.feature(ref,source[:1]),source[:1]),1)
def test_copy_comparison_cannot_be_diluted_by_octaves_runs_or_reattacks(self):
source=melody(((0,2,'c'),(2,4,'e'),(4,6,'g'),(6,8,'c')))
shifted=leads.shift(source,1)
self.assertTrue(similarity.near_copy(shifted,source,F(),F(8))['near_copy'])
repeated=tuple(part for n in shifted for part in (dataclasses.replace(n,end=n.start+1),dataclasses.replace(n,start=n.start+1,event_id=n.event_id+'r')))
self.assertTrue(similarity.near_copy(repeated,source,F(),F(8))['near_copy'])
self.assertEqual(similarity.compare(repeated,source)['octave_insensitive'],1)
shortened=tuple(dataclasses.replace(n,end=n.end-F(1,2)) for n in shifted)
self.assertEqual(similarity.compare(shortened,source)['octave_insensitive'],.75)
relocated=tuple(dataclasses.replace(n,start=n.start+8,end=n.end+8) for n in shifted)
self.assertTrue(similarity.near_copy(relocated,source,F(8),F(16))['near_copy'])
def test_tied_partition_does_not_change_similarity(self):
source=melody(((0,2,'c'),(2,4,'e'),(4,6,'g'),(6,8,'c')))
split=tuple(piece for n in source for piece in (dataclasses.replace(n,end=n.start+1),dataclasses.replace(n,start=n.start+1,continuation=True)))
self.assertEqual(similarity.compare(source,source),similarity.compare(split,source))
def test_downward_cross_collection_steps_preserve_the_actual_target(self):
from generator.solo import routes
scale=tests.scale_fixtures.library().scale('major',6,'D')
harmony=harmonic.Harmony(harmony_fixture.choices(('Dm7',),scale=scale))
first=dataclasses.replace(notes_fixture.note(0,1,'c'),pitch=types.Pitch('c',F(1),4))
last=dataclasses.replace(notes_fixture.note(1,2,'b'),pitch=types.Pitch('b',F(-1),3))
step,=themes.relative_steps((first,last),harmony)
self.assertEqual(step,-2)
self.assertEqual(routes.advance(scale,first.pitch.number(),step)[0].number(),last.pitch.number())
def test_two_note_route_uses_exact_scale_displacement(self):
from generator.solo import routes
harmony=harmonic.Harmony(harmony_fixture.choices(('C7',)))
notes=melody(((0,1,'g'),(1,2,'e')))
options=routes.candidates(notes,{'1'},harmony,tests.solo_fixtures.library(),(41,76))
self.assertTrue(options)
self.assertTrue(all(r.notes[-1].pitch==notes[-1].pitch for r in options))
def test_elaboration_may_continue_its_entry_instead_of_reattacking(self):
notes=melody(((0,3,'g'),(3,4,'e')))
source=types.SourcePhrase('held_entry',F(),F(4),notes,(),'')
harmony=harmonic.Harmony(harmony_fixture.choices(('C7',)))
options=leads.elaborate(notes,(),harmony,tests.solo_fixtures.library(),tests.rhythm_fixtures.library(),'4/4',source,sustain_entry=True)
self.assertTrue(options)
for actual,edit,route in options:
self.assertEqual(actual[0].start,0)
self.assertGreater(actual[0].end,edit.start)
self.assertEqual(actual[:len(route.notes)],route.notes)
self.assertTrue(harmony.evaluate(actual,complete=True).valid)
class ThemeProduction(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.document=fixture.song(count=8,chords=('CM7',))
cls.analysis=analysis.analyze(cls.document)
cls.context=realization.SearchContext()
cls.kw=dict(scales=tests.scale_fixtures.library(),sequences=tests.solo_fixtures.library(),rhythms=tests.rhythm_fixtures.library(),context=cls.context)
cls.solo=composer.compose(cls.analysis,cls.document['staves'][0]['measures'],'acoustic',F(32),**cls.kw)
def test_one_heard_theme_develops_across_source_boundaries(self):
self.assertEqual(self.solo.diagnostics['construction'],'thematic_recomposition')
self.assertEqual(len({p.state.home.id for p in self.solo.phrases}),1)
for phrase in self.solo.phrases:
self.assertTrue(drafts.contract_valid(phrase.draft,composer.phrase_notes(phrase))[0])
self.assertFalse(similarity.near_copy(phrase.refined.notes,themes.source_notes(self.analysis),phrase.plan.start,phrase.plan.end)['near_copy'])
self.assertEqual(self.solo.phrases[0].plan.closure,'open')
self.assertTrue(all(a.role=='intermediate' for a in self.solo.phrases[0].anchors))
self.assertEqual(self.solo.phrases[-1].anchors[-1].role,'terminal')
self.assertTrue(any(t.operation=='repeat_with_harmonic_adaptation' for p in self.solo.phrases for t in p.draft.transformations))
def test_library_routes_match_the_actual_complete_theme(self):
self.assertTrue(any(p.routes for p in self.solo.phrases))
for phrase in self.solo.phrases:
heard={n.event_id:n for n in composer.phrase_notes(phrase)}
for route in phrase.routes:
for note in route.notes:
actual=heard[note.event_id]
self.assertEqual((note.start,note.end,note.pitch),(actual.start,actual.end,actual.pitch))
self.assertEqual(route.notes[-1].pitch,route.destination.pitch)
def test_incoming_pitch_changes_concrete_continuation_pool(self):
harmony=harmonic.Harmony(self.solo.choices)
home=self.solo.phrases[0].state.home
ref=themes.home_reference(home,'answer')
context=realization.SearchContext()
outputs=[]
for octave in (3,4):
prior=dataclasses.replace(notes_fixture.note(15,16,'e'),pitch=types.Pitch('e',F(),octave))
state=types.ContinuationState('prior',last=prior,home=home)
options=themes.unit_candidates(ref,F(16),F(24),state,harmony,self.kw['sequences'],self.kw['rhythms'],'4/4',context,home=home,operation='answer')
self.assertTrue(options)
outputs.append({u.notes[0].pitch.number() for u in options})
self.assertNotEqual(*outputs)
def test_deterministic_cache_and_actual_source_input(self):
repeated=composer.compose(self.analysis,self.document['staves'][0]['measures'],'acoustic',F(32),**self.kw)
self.assertEqual(self.solo.notes(),repeated.notes())
changed=dataclasses.replace(self.analysis,melody=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,letter='g')) for n in self.analysis.melody))
other=composer.compose(changed,self.document['staves'][0]['measures'],'acoustic',F(32),**self.kw)
self.assertNotEqual(leads.signature(self.solo.notes()),leads.signature(other.notes()))
if __name__=='__main__':unittest.main()