Cleanup
This commit is contained in:
+11
-104
@@ -1,33 +1,14 @@
|
||||
"""Immutable source events, meter boundaries and tempo. No solo construction."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.score
|
||||
import generator.solo.types
|
||||
import dataclasses, fractions
|
||||
import generator.model, generator.score, generator.solo.types
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Analysis:
|
||||
motifs: tuple[generator.solo.types.Motif, ...]
|
||||
boundaries: tuple[fractions.Fraction, ...]
|
||||
boundaries: tuple
|
||||
tempo: fractions.Fraction
|
||||
melody: tuple[generator.solo.types.Note, ...]
|
||||
melody: tuple
|
||||
body_offset: fractions.Fraction = fractions.Fraction()
|
||||
source_phrases: tuple[generator.solo.types.SourcePhrase, ...] = ()
|
||||
|
||||
def identity(analysis, measures, part):
|
||||
anchor = analysis.melody[0].pitch.number() if analysis.melody else 0
|
||||
melody = [(str(n.start), str(n.end), n.pitch.number() - anchor) for n in analysis.melody]
|
||||
harmony = []
|
||||
for measure in measures:
|
||||
for chord in measure['harmonies']:
|
||||
pitches = sorted((int(p) - anchor) % 12 for p in generator.harmony.pitch_classes(chord)) if chord['kind'] != 'none' else []
|
||||
harmony.append((chord['kind'], pitches, chord['onset']))
|
||||
return hashlib.sha256(json.dumps((part, melody, harmony), sort_keys=True).encode()).hexdigest()
|
||||
|
||||
def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]:
|
||||
"""Collapse tied tones before extracting monophonic melody fragments."""
|
||||
@@ -55,83 +36,9 @@ def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]:
|
||||
absolute += generator.model.fraction(measure['duration'])
|
||||
return tuple(sorted(result, key=lambda n: (n.start, -n.pitch.number(), n.sources)))
|
||||
|
||||
def analyze(document: dict) -> Analysis:
|
||||
measures = generator.score.source_staff(document)['measures']
|
||||
melody = attacks(document)
|
||||
boundaries = [fractions.Fraction()]
|
||||
for measure in measures:
|
||||
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
|
||||
tempo = next((generator.model.fraction(t['bpm']) * generator.model.written_length(t['written']) for m in measures for t in m.get('tempos', [])), fractions.Fraction(120))
|
||||
candidates = []
|
||||
for index, measure in enumerate(measures):
|
||||
start = boundaries[index]
|
||||
# Legacy motif consumers also receive complete longer source material.
|
||||
for count in (1, 2, 4, 6, 8):
|
||||
if index + count >= len(boundaries):
|
||||
continue
|
||||
end = boundaries[index + count]
|
||||
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:])):
|
||||
continue
|
||||
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')
|
||||
identifier = 'head_' + hashlib.sha256(identity).hexdigest()[:12]
|
||||
context = tuple(generator.harmony.symbol(h) for m in measures[index:index + count] for h in m['harmonies'])
|
||||
candidates.append(generator.solo.types.Motif(identifier, normalized, end - start, context))
|
||||
unique = {m.id: m for m in reversed(candidates)}
|
||||
# Preserve the opening idea, then favor a contrasting rhythm/contour with space.
|
||||
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))
|
||||
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)
|
||||
def analyze(document):
|
||||
measures=generator.score.source_staff(document)['measures']
|
||||
boundaries=[fractions.Fraction()]
|
||||
for m in measures:boundaries.append(boundaries[-1]+generator.model.fraction(m['duration']))
|
||||
tempo=next((generator.model.fraction(t['bpm'])*generator.model.written_length(t['written']) for m in measures for t in m.get('tempos',[]) if 'bpm' in t),fractions.Fraction(120))
|
||||
return Analysis(tuple(boundaries),tempo,attacks(document),boundaries[generator.score.body_start_measure_index(measures)])
|
||||
|
||||
@@ -37,58 +37,3 @@ def annotate(document, solo, offset):
|
||||
else:
|
||||
spans.append({'id': f'{solo.part}_scale_{len(spans) + 1}', 'start': generator.model.rational(offset + choice.span.start), 'end': generator.model.rational(offset + choice.span.end), 'label': label, 'matched': True})
|
||||
document['staves'][0]['scale_spans'] = spans
|
||||
|
||||
def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None, references=(), stage='elaborated', enclosures_enabled=True):
|
||||
"""Compose once per song, replacing only already-placed solo section windows."""
|
||||
if references:
|
||||
raise ValueError('Audition references are not production composition inputs')
|
||||
from generator.arrange import generate
|
||||
analysis = generator.solo.analysis.analyze(source)
|
||||
sections = regions(source)
|
||||
source_measures = generator.score.source_staff(source)['measures']
|
||||
reports = []
|
||||
requests = {}
|
||||
for section_index, part in ((3, 'acoustic'), (4, 'electric')):
|
||||
first, count, offset, length = sections[section_index]
|
||||
form = generator.arrange.form.full_arrangement_sections(source_measures, part)
|
||||
section = form[section_index]
|
||||
effective = generate.section_source(source_measures, section, form[section_index+1])
|
||||
initial = generator.accompaniment.carried_chords(effective)[section.source_measure_index]
|
||||
template = copy.deepcopy(effective[section.source_measure_index:section.source_measure_index+count])
|
||||
cutoff = length
|
||||
if part == 'electric' and generator.score.body_start_measure_index(source_measures):
|
||||
pickup = source_measures[0]
|
||||
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']]
|
||||
if sounding:
|
||||
cutoff -= generator.model.fraction(pickup['duration']) - min(sounding)
|
||||
requests[part] = (template, cutoff, initial)
|
||||
selected = generator.solo.composer.compose_pair(analysis, requests, scales=scales, sequences=sequences, rhythms=rhythms, contexts=contexts, stage=stage,enclosures_enabled=enclosures_enabled)
|
||||
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:
|
||||
solos.append(solo)
|
||||
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
|
||||
generator.solo.notation.replace_region(parts[part], first, foreground)
|
||||
annotate(parts[part], solo, offset)
|
||||
# The other instrument already contains its canonical accompaniment.
|
||||
# Installing a solo must never rewrite the learned supporting part.
|
||||
# Canonical supporting notes must remain compatible throughout every
|
||||
# overlapped mapped span, independent of the foreground realization.
|
||||
elapsed = fractions.Fraction()
|
||||
for measure in parts[other]['staves'][0]['measures'][first:first+count]:
|
||||
for event in generator.score.measure_notes(measure):
|
||||
start = elapsed + generator.model.fraction(event['onset'])
|
||||
end = min(cutoff, start + generator.model.fraction(event['duration']))
|
||||
if start >= end: continue
|
||||
allowed = generator.solo.types.permitted(solo.choices,start,end)
|
||||
if any(int(generator.model.pitch_number(p)) % 12 not in allowed for p in event['pitches']):
|
||||
raise ValueError(f'{other} accompaniment, measure {measure["number"]}: canonical voicing outside solo map')
|
||||
elapsed += generator.model.fraction(measure['duration'])
|
||||
solo.diagnostics['canonical_accompaniment'] = generator.arrange.accompaniment.audit(source, parts)
|
||||
reports.append(generator.solo.report.describe(solo, scales, first, offset))
|
||||
for document in parts.values():
|
||||
generator.model.validate_document(document)
|
||||
generator.fretboard.validate_document(document)
|
||||
return '\n'.join(reports)
|
||||
|
||||
+40
-129
@@ -1,136 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
"""The single production entry point for independent phrase composition."""
|
||||
from fractions import Fraction as F
|
||||
import time
|
||||
import dataclasses
|
||||
from generator import harmony as chords, vocabulary
|
||||
from generator.scales import timeline, selection
|
||||
from generator.solo import types, profiles, harmonic, construction, placement, policy
|
||||
|
||||
import generator.solo.enclosures
|
||||
import generator.solo.profiles
|
||||
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.scales.selection
|
||||
import generator.scales.timeline
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.analysis
|
||||
import generator.solo.planning
|
||||
import generator.solo.policy
|
||||
import generator.solo.realization
|
||||
import generator.solo.types
|
||||
|
||||
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated', mode='thematic', enclosures_enabled=True):
|
||||
if references or context is not None and getattr(context,'references',()):
|
||||
raise ValueError('Audition references are not production composition inputs')
|
||||
started = time.monotonic()
|
||||
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
|
||||
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
|
||||
choices = generator.scales.selection.select(timeline, region, scales)
|
||||
for choice in choices:
|
||||
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')
|
||||
from generator.solo import leads
|
||||
context = context if context is not None else generator.solo.realization.SearchContext()
|
||||
context.expressions_enabled = enclosures_enabled
|
||||
options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode)
|
||||
result = options[0][1]
|
||||
result.diagnostics['strict_composition_seconds'] = time.monotonic()-started
|
||||
from generator.solo import expressions
|
||||
if mode!='thematic':result = expressions.refine(result,False)
|
||||
result.diagnostics['enclosures_enabled']=enclosures_enabled
|
||||
result.diagnostics['expressions_selected']=[e.kind for p in result.phrases for e in p.draft.expressions]
|
||||
def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,expressions_enabled=True):
|
||||
began=time.monotonic()
|
||||
choices=selection.select(timeline.read({'measures':measures},initial=initial),timeline.Solo(F(),end,part.capitalize()+' Solo'),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}, beat {c.span.beat}, {chords.symbol(c.span.chord)}: No exact match within the permitted catalog')
|
||||
profile=profiles.for_part(part,analysis);harmony=harmonic.Harmony(choices,profile)
|
||||
source=tuple((n.start,n.end,n.pitch) for n in analysis.melody)
|
||||
seed=vocabulary.role().get('choice_seed') or policy.stable_rank(part,(source,tuple((c.span.start,c.span.end,c.span.chord) for c in choices),vocabulary.role(part+'_solo'),policy.DEFAULT.composition_version))
|
||||
builder=construction.Builder(analysis,measures,harmony,sequences,rhythms,end,seed,expressions_enabled)
|
||||
phrases=builder.compose()
|
||||
# Resolve hand-position transitions over the selected whole melody. This
|
||||
# cannot reselect pitches, rhythm or another independently generated part.
|
||||
raw=tuple(n for p in phrases for n in p.notes)
|
||||
placed=placement.place(raw,analysis.tempo,profile=profile) if raw else (0,())
|
||||
if placed is None:raise construction.SearchExhausted('selected melody has no complete legal guitar path')
|
||||
by_id={n.event_id:n for n in placed[1]}
|
||||
phrases=tuple(dataclasses.replace(p,notes=tuple(by_id[n.event_id] for n in p.notes)) for p in phrases)
|
||||
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'complete_phrases','version':policy.DEFAULT.composition_version,
|
||||
'construction_seconds':time.monotonic()-began,**builder.timings,'operation_coverage':{k:dict(v) for k,v in builder.coverage.items()},
|
||||
'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile)
|
||||
validate(result)
|
||||
return result
|
||||
|
||||
|
||||
def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic', enclosures_enabled=True):
|
||||
"""Compatibility batch helper: each winner is selected independently."""
|
||||
return tuple(compose(analysis, measures, part, end, scales=scales, sequences=sequences,
|
||||
rhythms=rhythms, initial=initial, context=(contexts or {}).get(part), stage=stage,
|
||||
mode=mode, enclosures_enabled=enclosures_enabled)
|
||||
for part, (measures, end, initial) in requests.items())
|
||||
|
||||
|
||||
def phrase_notes(phrase):
|
||||
notes = tuple(n for d in phrase.decisions for n in d.notes)
|
||||
if notes and notes[0].continuation:
|
||||
notes = (dataclasses.replace(notes[0], continuation=False), *notes[1:])
|
||||
return generator.solo.types.sounding_notes(notes)
|
||||
|
||||
def transition_cost(first, second):
|
||||
before, after = phrase_notes(first), phrase_notes(second)
|
||||
if not before or not after:
|
||||
return 0.0
|
||||
gap = max(0, after[0].start - before[-1].end - first.plan.release)
|
||||
jump = abs(after[0].pitch.number() - before[-1].pitch.number())
|
||||
# The next phrase's local score already includes physical movement. Here we
|
||||
# evaluate pacing and conversational contrast at the join, once per pair.
|
||||
before_shape = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(before, before[1:]))
|
||||
after_shape = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(after, after[1:]))
|
||||
repeated_answer = second.plan.relationship == 'answer' and before_shape == after_shape
|
||||
return min(1, float(gap) / 2) + 0.5 * min(1, max(0, jump - 5) / 12) + 0.5 * repeated_answer
|
||||
|
||||
def choose_phrases(plans, realize, revise_pair=None):
|
||||
"""Commit only after the next phrase has been evaluated from each ending."""
|
||||
result, previous, pending = [], None, None
|
||||
for index, plan in enumerate(plans):
|
||||
options = realize(plan, previous)
|
||||
if pending is not None and pending not in options:
|
||||
options = (pending, *options)
|
||||
ranked, failure = [], None
|
||||
for option in options:
|
||||
notes = phrase_notes(option)
|
||||
last = notes[-1] if notes else previous
|
||||
incoming = transition_cost(result[-1], option) if result else 0
|
||||
if index + 1 == len(plans):
|
||||
ranked.append((option.score + incoming, option, None))
|
||||
continue
|
||||
try:
|
||||
following = realize(plans[index + 1], option.state or last)
|
||||
except generator.solo.realization.SearchExhausted as error:
|
||||
failure = error
|
||||
continue
|
||||
length = float(option.plan.end - option.plan.start)
|
||||
next_length = float(plans[index + 1].end - plans[index + 1].start)
|
||||
shared_passage = bool(plan.passage_id and plan.passage_id == plans[index + 1].passage_id)
|
||||
for future in following:
|
||||
left, right = revise_pair(option, future, previous) if revise_pair else (option, future)
|
||||
cost = (right.score if shared_passage else (left.score * length + right.score * next_length) / (length + next_length)) + transition_cost(left, right)
|
||||
ranked.append((cost + incoming, left, right))
|
||||
if not ranked:
|
||||
raise failure or generator.solo.realization.SearchExhausted(f'{plan.id}: no feasible phrase transition')
|
||||
_, chosen, pending = min(ranked, key=lambda item: (item[0], tuple((n.pitch, n.start, n.end, n.position) for n in phrase_notes(item[1]))))
|
||||
result.append(chosen)
|
||||
notes = phrase_notes(chosen)
|
||||
if chosen.state:
|
||||
previous = chosen.state
|
||||
elif notes:
|
||||
previous = notes[-1]
|
||||
return tuple(result)
|
||||
|
||||
def validate(solo):
|
||||
previous = None
|
||||
profile = solo.profile or generator.solo.profiles.for_part(solo.part)
|
||||
previous=None
|
||||
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:
|
||||
raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
|
||||
if note.position not in generator.solo.placement.positions(note.pitch.number(), profile):
|
||||
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
|
||||
previous = note
|
||||
for phrase in solo.phrases:
|
||||
notes = phrase_notes(phrase)
|
||||
if not generator.solo.realization.target_ok(notes, phrase.plan.target):
|
||||
raise ValueError(f'{phrase.plan.id}: planned harmonic arrival was not realized')
|
||||
harmony = generator.solo.harmonic.Harmony(solo.choices)
|
||||
contracts = tuple(dict.fromkeys(t for phrase in solo.phrases if phrase.state for t in phrase.state.treatments))
|
||||
enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures)
|
||||
expressions = tuple(e for p in solo.phrases if p.draft for e in p.draft.expressions)
|
||||
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures,expressions=expressions)
|
||||
if not checked.valid:
|
||||
raise ValueError(f'{solo.part} solo: {checked.error}')
|
||||
for phrase in solo.phrases:
|
||||
if phrase.state:
|
||||
complete = phrase.plan.end == (phrase.plan.passage_end or phrase.plan.end)
|
||||
valid, error = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=complete, closure=phrase.plan.closure)
|
||||
if not valid:
|
||||
raise ValueError(error)
|
||||
if note.start<0 or note.end>solo.end or note.end<=note.start or previous and previous.end>note.start:raise ValueError('overlapping/out-of-bounds sounding event')
|
||||
if note.position not in placement.positions(note.pitch.number(),solo.profile):raise ValueError('invalid guitar placement')
|
||||
previous=note
|
||||
expressions=tuple(c for p in solo.phrases for c in p.expressions)
|
||||
checked=harmonic.Harmony(solo.choices).evaluate(solo.notes(),until=solo.end,complete=True,expressions=expressions)
|
||||
if not checked.valid:raise ValueError(checked.error)
|
||||
cursor=F()
|
||||
for p in solo.phrases:
|
||||
if p.start!=cursor or p.end<=p.start:raise ValueError('phrase coverage gap or overlap')
|
||||
cursor=p.end
|
||||
valid,error=harmonic.anchors_valid((p.destination,) if p.destination else (),p.notes,p.end,complete=True,closure='closed' if p.ending in ('complete','handoff') else 'open')
|
||||
if not valid:raise ValueError(error)
|
||||
if cursor!=solo.end:raise ValueError('incomplete solo duration')
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Construct complete phrases directly from harmonic/rhythmic purposes.
|
||||
|
||||
The head is optional material, never a compulsory prefix or rhythmic template.
|
||||
No reference score, completed part, or previous published output enters here.
|
||||
"""
|
||||
import dataclasses
|
||||
import time
|
||||
from collections import Counter
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary, harmony as chords, model
|
||||
from generator.solo import types, expressions, groups, routes, rhythm, scoring, placement, policy
|
||||
|
||||
class SearchExhausted(ValueError):pass
|
||||
|
||||
def choose(values,seed,domain):
|
||||
return min(values,key=lambda v:policy.stable_rank(seed,(domain,v)))
|
||||
|
||||
class Builder:
|
||||
def __init__(self,analysis,measures,harmony,sequences,rhythms,end,seed,enabled=True):
|
||||
self.analysis=analysis;self.harmony=harmony;self.end=end;self.seed=seed;self.enabled=enabled
|
||||
self.rules=vocabulary.data('solo');self.budgets=self.rules['search'];self.routes=routes.Routes(harmony,sequences)
|
||||
self.rhythms=rhythm.Rhythms(rhythms,analysis.tempo);self.windows=[];cursor=F()
|
||||
for m in measures:
|
||||
length=model.fraction(m['duration']);self.windows.append((cursor,cursor+length,m['time_signature']));cursor+=length
|
||||
self.coverage={a:Counter() for a in self.rules['actions']};self.failures=Counter();self.cache={};self.decisions=[];self.timings=Counter()
|
||||
|
||||
def at(self,time):return next((m for m in self.windows if m[0]<=time<m[1]),self.windows[-1])
|
||||
|
||||
def actions(self,history):
|
||||
result=['arpeggio','scalar','neighbor']
|
||||
if history:
|
||||
result+=['develop','answer']
|
||||
if len(history)>1 and history[-1].purpose!='return':result.append('return')
|
||||
if self.analysis.melody and len(history)>2:result.append('head')
|
||||
preferred=vocabulary.role().get('preferred_actions',[])
|
||||
return sorted(result,key=lambda a:(a not in preferred,preferred.index(a) if a in preferred else 0,a))
|
||||
|
||||
def propose(self,start,history,variant,batch=0):
|
||||
incoming=next((p.notes[-1] for p in reversed(history) if p.notes),None)
|
||||
home=next((p for p in history if len(p.notes)>=4),None)
|
||||
seed=policy.stable_rank(self.seed,('phrase',start,tuple(p.id for p in history[-2:]),variant,batch))
|
||||
purpose=self.actions(history)[variant%len(self.actions(history))]
|
||||
self.coverage[purpose]['proposed']+=1
|
||||
_,_,meter=self.at(start);bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
||||
span=self.harmony.at(start)
|
||||
if not span or not span.scale:return None
|
||||
limit=min(self.end,start+self.rules['max_phrase_bars']*bar)
|
||||
# Source harmony boundaries and available time supply opportunities, not mandatory closes.
|
||||
opportunities=sorted({min(self.end,start+2*bar),*(s.start for s in self.harmony.spans if start+2<=s.start<limit),*(s.end for s in self.harmony.spans if start+2<=s.end<limit),self.end if self.end<=limit else limit})
|
||||
boundary=choose(opportunities,seed,'destination time')
|
||||
ending=choose(('open','settle','complete'),seed,'ending')
|
||||
if boundary==self.end:ending='handoff'
|
||||
elif history and history[-1].ending=='open':ending=choose(('settle','complete'),seed,'complete continuation')
|
||||
offset=choose(tuple(map(F,self.rules['arrival_offsets'])) if self.enabled else (F(),F(1,2)),seed,'arrival')
|
||||
arrival=boundary+offset
|
||||
if ending=='open':arrival=min(boundary,self.end)-F(1,2)
|
||||
if ending=='handoff':arrival=self.end-choose((F(2),F(5,2),F(3)),seed,'handoff hold')
|
||||
arrival=max(start+(F(1) if ending=='handoff' else F(3,2)),min(arrival,self.end-F(1,2)))
|
||||
if arrival<=start:return None
|
||||
anticipated=arrival<boundary and ending!='open' and any(s.start==boundary for s in self.harmony.spans) and boundary-arrival<=F(vocabulary.data('harmony')['anticipation_max'])
|
||||
harmonic_time=boundary if anticipated else arrival
|
||||
target_span=self.harmony.at(harmonic_time)
|
||||
if not target_span or not target_span.scale:return None
|
||||
hold=F(1,2) if ending=='open' else choose(tuple(map(F,self.rules['holds'])),seed,'hold')
|
||||
if ending in ('complete','handoff'):hold=max(F(2),hold)
|
||||
release=min(self.end,arrival+hold)
|
||||
if anticipated and release<=boundary:return None
|
||||
if ending=='handoff':release=self.end
|
||||
breath=F() if ending=='open' else choose(tuple(map(F,self.rules['breaths'])),seed,'breath')
|
||||
finish=min(self.end,release+breath)
|
||||
if 0<self.end-finish<3:return None
|
||||
if finish==self.end and ending!='open':
|
||||
if release-arrival<min(F(2),self.end-start-1):return None
|
||||
ending='handoff'
|
||||
center=self.harmony.profile.reference or 60
|
||||
# A coherent excursion and return; no per-note octave folding or string quota.
|
||||
shift=choose(tuple(vocabulary.role().get('register_alternatives',(-7,0,7))),seed,'register')
|
||||
if incoming and incoming.pitch.number()>center+7:shift=min(0,shift)
|
||||
if incoming and incoming.pitch.number()<center-7:shift=max(0,shift)
|
||||
goal=center+shift
|
||||
permitted=types.permitted(self.harmony.choices,harmonic_time,release)
|
||||
members=self.harmony.members(harmonic_time,release)
|
||||
targets=[types.scale_pitch(target_span.scale,i) for i in range(-21,22)]
|
||||
targets=[p for p in targets if p.number()%12 in permitted and (ending=='open' or p.number()%12 in members) and self.harmony.profile.permits(p.number()) and abs(p.number()-goal)<=7]
|
||||
if not targets:return None
|
||||
# Choose among actual chord degrees and registers, without always picking the nearest fifth/root.
|
||||
target_pitch=choose(targets,seed,'destination pitch')
|
||||
target=types.Note(arrival,release,target_pitch,role='arrival' if ending!='open' else 'motion',event_id=seed[:16]+':target')
|
||||
contracts=[]
|
||||
if anticipated:contracts.append(types.ExpressionContract(seed[:16]+':anticipation','anticipation','early_chord_tone',(target,),target,target_span.chord,boundary=boundary))
|
||||
lead=();movement_end=arrival
|
||||
# Shape-changing expression is planned before either rhythm or route is realized.
|
||||
treatment=choose(('direct','approach','color','blues','suspension'),seed,'expression') if self.enabled and ending!='open' else 'direct'
|
||||
if treatment=='approach':
|
||||
rule=choose(vocabulary.data('lead_ins')['patterns'],seed,'approach rule')
|
||||
movement_end=arrival-F(rule['gap'])-sum(map(F,rule['durations']),F());cursor=movement_end;events=[]
|
||||
for i,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])):
|
||||
events.append(types.Note(cursor,cursor+F(duration),expressions.chromatic(target_pitch,step),role=rule['kind'],event_id=seed[:16]+':approach:'+str(i)));cursor+=F(duration)
|
||||
lead=tuple(events)
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':approach',rule['kind'],rule['id'],lead,target,target_span.chord,boundary=boundary if anticipated else None))
|
||||
elif treatment=='color' and not anticipated:
|
||||
region=self.harmony.at(arrival-1)
|
||||
eligible=[r for r in vocabulary.data('harmony')['colors'] if region and expressions.eligible_color(r,region,self.harmony)]
|
||||
if eligible:
|
||||
rule=choose(eligible,seed,'color rule');p=expressions.degree_pitch(chords.parse_symbol(region.chord),rule['degree'],rule['alter'],target_pitch.number())
|
||||
if not rule.get('resolve') and choose((True,False),seed,'sustained color'):
|
||||
target=dataclasses.replace(target,pitch=p,role='color');target_pitch=p
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':color','color',rule['id'],(target,),target,target_span.chord))
|
||||
else:
|
||||
color=types.Note(arrival-1,arrival,p,role='color',event_id=seed[:16]+':color')
|
||||
if rule.get('resolve') and not 0<abs(target_pitch.number()-p.number())<=2:return None
|
||||
lead=(color,);movement_end=color.start
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':color','color',rule['id'],lead,target if rule.get('resolve') else color,target_span.chord if rule.get('resolve') else region.chord))
|
||||
elif treatment=='blues' and not anticipated:
|
||||
eligible=[r for r in vocabulary.data('lead_ins')['blues'] if expressions.degree_pitch(chords.parse_symbol(target_span.chord),r['target_degree'],0,target_pitch.number())==target_pitch]
|
||||
if eligible:
|
||||
rule=choose(eligible,seed,'blues rule');p=expressions.degree_pitch(chords.parse_symbol(target_span.chord),rule['degree'],rule['alter'],target_pitch.number())
|
||||
lead=(types.Note(arrival-F(1,2),arrival,p,role='blues',event_id=seed[:16]+':blues'),);movement_end=lead[0].start
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':blues','blues',rule['id'],lead,target,target_span.chord))
|
||||
elif treatment=='suspension' and offset>0 and arrival==boundary+offset:
|
||||
previous=self.harmony.at(boundary-F(1,2));rule=vocabulary.data('harmony')['suspensions'][0]
|
||||
if previous and previous.scale:
|
||||
candidates=[p for p in self.routes.pitches(boundary-F(1,2),arrival) if p.number()%12 in previous.roles.basic and target_pitch.number()-p.number() in rule['steps']]
|
||||
if candidates:
|
||||
p=choose(candidates,seed,'suspension pitch');lead=(types.Note(boundary-F(1,2),arrival,p,role='suspension',event_id=seed[:16]+':suspension'),);movement_end=lead[0].start
|
||||
contracts.append(types.ExpressionContract(seed[:16]+':suspension','suspension',rule['id'],lead,target,target_span.chord,boundary=boundary))
|
||||
entrance=F() if history and history[-1].ending=='open' else choose(tuple(map(F,self.rules['entrances'])),seed,'entrance')
|
||||
entry=start+entrance
|
||||
if movement_end-entry<F(1):return None
|
||||
hint=home.notes[:4] if home else ()
|
||||
if purpose=='head':
|
||||
candidates=[self.analysis.melody[i:i+4] for i in range(max(0,len(self.analysis.melody)-3))]
|
||||
candidates=[ns for ns in candidates if ns[-1].end-ns[0].start<=movement_end-entry]
|
||||
if not candidates:return None
|
||||
hint=choose(candidates,seed,'head reference')
|
||||
rhythm_purpose=purpose if purpose in ('arpeggio','scalar','neighbor','develop','answer') else 'develop'
|
||||
rhythm_began=time.monotonic()
|
||||
timing,rhythm_groups,sources=self.rhythms.realize(entry,movement_end,rhythm_purpose,seed,meter)
|
||||
self.timings['rhythm_seconds']+=time.monotonic()-rhythm_began
|
||||
if not timing:return None
|
||||
if purpose in ('return','head'):
|
||||
if len(hint)<3:return None
|
||||
duration=hint[-1].end-hint[0].start
|
||||
if entry+duration>movement_end:return None
|
||||
pattern=tuple((entry+n.start-hint[0].start,entry+n.end-hint[0].start) for n in hint)
|
||||
tail,tail_groups,tail_sources=self.rhythms.realize(entry+duration,movement_end,'develop',seed,meter)
|
||||
timing=(*pattern,*tail)
|
||||
if not groups.valid(tuple((a-entry,b-entry) for a,b in timing)):return None
|
||||
rhythm_groups=(*groups.describe(tuple((a-entry,b-entry) for a,b in pattern),origin=entry,source='heard_theme' if purpose=='return' else 'head'),*tail_groups)
|
||||
sources=(purpose,*tail_sources)
|
||||
self.coverage[purpose]['rhythm_valid']+=1
|
||||
first_span=self.harmony.at(entry)
|
||||
starting=[p for p in self.routes.pitches(*timing[0]) if p.number()%12 in first_span.roles.basic and abs(p.number()-(incoming.pitch.number() if incoming else goal))<=7]
|
||||
if not starting:return None
|
||||
first=choose(starting,seed,'entry pitch')
|
||||
notes=tuple(types.Note(a,b,first,role=purpose,event_id=seed[:16]+':'+str(i)) for i,(a,b) in enumerate(timing))
|
||||
all_notes=(*notes,*lead,target)
|
||||
fixed={0,*range(len(notes),len(all_notes))}
|
||||
direction=choose(self.rules['directions'],seed,'contour')
|
||||
route_began=time.monotonic()
|
||||
paths=self.routes.realize(all_notes,fixed,purpose,direction,seed,goal,incoming,hint)
|
||||
self.timings['route_seconds']+=time.monotonic()-route_began
|
||||
self.coverage[purpose]['complete_routes']+=len(paths)
|
||||
for actual in paths:
|
||||
bound=[]
|
||||
for c in contracts:
|
||||
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
|
||||
bound.append(dataclasses.replace(c,preparation=prior if c.kind in ('connection','blues') else c.preparation))
|
||||
validation_began=time.monotonic()
|
||||
checked=self.harmony.evaluate(actual,until=finish,complete=True,expressions=tuple(bound))
|
||||
self.timings['harmonic_seconds']+=time.monotonic()-validation_began
|
||||
if not checked.valid:self.failures[checked.error.split(':')[-1]]+=1;continue
|
||||
anchor=None if ending=='open' else types.Anchor(seed[:16]+':destination',arrival,arrival,target_pitch,members|frozenset({target_pitch.number()%12}),self.harmony.profile.register,release-arrival,'terminal' if ending in ('complete','handoff') else 'intermediate',target_span.chord,False,release,ending)
|
||||
phrase=types.Phrase(seed[:16],start,finish,actual,purpose,direction,ending,anchor,tuple(bound),rhythm_groups,home.id if home else seed[:16],sources=sources)
|
||||
evaluation_began=time.monotonic()
|
||||
evaluation=scoring.evaluate(phrase,incoming,history,center)
|
||||
self.timings['evaluation_seconds']+=time.monotonic()-evaluation_began
|
||||
self.coverage[purpose]['valid']+=1
|
||||
return dataclasses.replace(phrase,score=evaluation.total,evaluation=evaluation)
|
||||
return None
|
||||
|
||||
def candidates(self,start,history):
|
||||
key=(start,len(history),next(((p.id,p.notes) for p in history if len(p.notes)>=4),None),tuple((p.id,p.purpose,p.ending,p.notes,p.expressions) for p in history[-4:]))
|
||||
if key in self.cache:return self.cache[key]
|
||||
span=self.harmony.at(start)
|
||||
if not span or not span.scale:
|
||||
finish=min(self.end,span.end if span else self.end)
|
||||
result=(types.Phrase('space:'+str(start),start,finish,(),'space','none','open'),)
|
||||
self.cache[key]=result;return result
|
||||
for action in self.actions(history):self.coverage[action]['eligible_contexts']+=1
|
||||
proposals=[]
|
||||
for batch in range(self.budgets['replans']+1):
|
||||
for variant in range(self.budgets['proposals']):
|
||||
p=self.propose(start,history,variant,batch)
|
||||
if p:proposals.append(p)
|
||||
if proposals:break
|
||||
if not proposals:raise SearchExhausted(f'at beat {start}: no complete phrase within deterministic budget; rejections {self.failures.most_common(3)}; not proof of harmonic incompatibility')
|
||||
from generator.solo.sequences import diverse
|
||||
finals=diverse(proposals,lambda p:(p.score,policy.stable_rank(self.seed,p.id)),lambda p:(p.purpose,p.ending,p.direction),self.budgets['finalists'])
|
||||
placed=[];incoming=next((p.notes[-1] for p in reversed(history) if p.notes),None)
|
||||
for p in finals:
|
||||
self.coverage[p.purpose]['finalists']+=1
|
||||
placement_began=time.monotonic()
|
||||
options=placement.alternatives(p.notes,self.analysis.tempo,incoming,self.budgets['fingerings'],self.harmony.profile)
|
||||
self.timings['placement_seconds']+=time.monotonic()-placement_began
|
||||
if options:
|
||||
# Physical economy selects a fingering of this same melody, not the tune itself.
|
||||
placed.append(dataclasses.replace(p,notes=options[0][1]));self.coverage[p.purpose]['placed']+=1
|
||||
self.cache[key]=tuple(placed)
|
||||
return self.cache[key]
|
||||
|
||||
def compose(self):
|
||||
history=[];start=F()
|
||||
while start<self.end:
|
||||
options=self.candidates(start,tuple(history));ranked=[]
|
||||
for p in options[:self.budgets['frontier']]:
|
||||
future=()
|
||||
if p.end<self.end:
|
||||
try:future=self.candidates(p.end,(*history,p))
|
||||
except SearchExhausted:continue
|
||||
if not future:continue
|
||||
next_score=min((f.score for f in future),default=p.score)
|
||||
total=(p.score+next_score)/2
|
||||
ranked.append((total,policy.stable_rank(self.seed,p.id),p))
|
||||
if p.purpose in self.coverage:self.coverage[p.purpose]['lookahead_valid']+=1
|
||||
if not ranked:raise SearchExhausted(f'at beat {start}: no legal following phrase within lookahead budget')
|
||||
_,_,chosen=min(ranked,key=lambda v:v[:2]);history.append(chosen);start=chosen.end
|
||||
if chosen.purpose in self.coverage:self.coverage[chosen.purpose]['selected']+=1
|
||||
self.decisions.append({'start':str(chosen.start),'selected':chosen.id,'candidates':[{'id':p.id,'purpose':p.purpose,'end':str(p.end),'evaluation':dataclasses.asdict(p.evaluation) if p.evaluation else {},'lookahead_total':next((v[0] for v in ranked if v[2].id==p.id),None)} for p in options]})
|
||||
return tuple(history)
|
||||
@@ -1,414 +0,0 @@
|
||||
"""Phrase-wide rhythm construction and contracts for concrete melodic drafts.
|
||||
|
||||
No bar winners are assembled here. A rhythm is stated, transformed in place,
|
||||
and timed to its destinations before the pitch search begins.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
|
||||
import generator.model
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.policy
|
||||
import generator.solo.scoring
|
||||
import generator.solo.sequences
|
||||
import generator.solo.types
|
||||
import generator.solo.features
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Slot:
|
||||
id: str
|
||||
start: F
|
||||
end: F
|
||||
group: int
|
||||
reference: int
|
||||
forced: generator.solo.types.Pitch | None = None
|
||||
protected: bool = False
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Group:
|
||||
id: str
|
||||
operation: str
|
||||
start: F
|
||||
end: F
|
||||
source: str
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RhythmDraft:
|
||||
id: str
|
||||
slots: tuple[Slot, ...]
|
||||
groups: tuple[Group, ...]
|
||||
anchors: tuple[generator.solo.types.Anchor, ...]
|
||||
reference: generator.solo.types.Motif | generator.solo.types.RealizedMotif
|
||||
relationship: str
|
||||
octave: int = 0
|
||||
|
||||
def catalog(library, meter):
|
||||
return _catalog(library.for_meter(meter))
|
||||
|
||||
@functools.lru_cache(maxsize=32)
|
||||
def _catalog(snippets):
|
||||
"""Read complete snippets; ties across authored bars stay one attack."""
|
||||
result = {}
|
||||
for snippet in snippets:
|
||||
offset, spans = F(), []
|
||||
for bar in snippet.bars:
|
||||
for event in bar.events:
|
||||
if not event.sounding:
|
||||
continue
|
||||
start, end = offset + event.onset, offset + event.onset + event.duration
|
||||
if event.continuation:
|
||||
if spans and spans[-1][1] == start:
|
||||
spans[-1] = (spans[-1][0], end)
|
||||
else:
|
||||
spans.append((start, end))
|
||||
offset += bar.length
|
||||
if 2 <= len(spans) <= 12:
|
||||
result.setdefault(tuple(spans), (snippet.id, offset))
|
||||
return tuple((identifier, spans, length) for spans, (identifier, length) in result.items())
|
||||
|
||||
def templates(plan, reference, library, meter):
|
||||
source = tuple((n.start, n.end) for n in reference.notes)
|
||||
if plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
|
||||
return tuple([(reference.id, source, reference.duration)])
|
||||
# A compact identifying portion, including its authored internal rests.
|
||||
source = source[:6]
|
||||
result = []
|
||||
if source:
|
||||
length = min(reference.duration, source[-1][1] + F(1, 2))
|
||||
result.append((reference.id, source, length))
|
||||
choices = catalog(library, meter)
|
||||
ranked = generator.solo.sequences.diverse(choices,
|
||||
lambda item: (abs(len(item[1]) / float(item[2]) - float(plan.activity)) > 0.75, generator.solo.policy.stable_rank(plan.seed, item)),
|
||||
lambda item: (len(item[1]), bool(item[1][0][0]), item[2]), 4)
|
||||
result.extend(ranked)
|
||||
return tuple(result)
|
||||
|
||||
def reopen(slots, cursor):
|
||||
"""An extension changes the previous provisional ending, not its core."""
|
||||
if not slots:
|
||||
return slots, cursor
|
||||
last = slots[-1]
|
||||
if last.protected or last.forced:
|
||||
return slots, cursor
|
||||
end = max(last.start + F(1, 2), last.end - F(1, 2))
|
||||
return (*slots[:-1], dataclasses.replace(last, end=end)), end
|
||||
|
||||
def develop_timing(pattern, period, unit, occupancy, connect):
|
||||
"""Diminish/broaden one established rhythm, retaining order and rests.
|
||||
|
||||
Ordinary diminution stops at eighth notes. Connected development may
|
||||
broaden existing sounds into part of their following rest, not insert a new
|
||||
independent pattern. These choices precede pitch/harmonic realization.
|
||||
"""
|
||||
result, cursor, previous = [], F(), F()
|
||||
for start, end in pattern:
|
||||
cursor += (start - previous) * unit
|
||||
duration = max(F(1, 2), (end - start) * unit) if unit < 1 else (end - start) * unit
|
||||
result.append((cursor, cursor + duration))
|
||||
cursor += duration
|
||||
previous = end
|
||||
length = cursor + (period - previous) * unit
|
||||
if connect:
|
||||
needed = max(F(), occupancy * length - sum(b - a for a, b in result))
|
||||
for i, (a, b) in enumerate(result):
|
||||
following = result[i + 1][0] if i + 1 < len(result) else length
|
||||
# Occupancy is a soft objective, not a duration denominator. Use a half
|
||||
# beat or the existing rest endpoint; never manufacture e.g. 7/40 beats
|
||||
# solely to hit a percentage exactly.
|
||||
extra = min(F(1, 2), following - b)
|
||||
if abs(needed - extra) >= needed:
|
||||
extra = F()
|
||||
result[i] = (a, b + extra)
|
||||
needed -= extra
|
||||
return tuple(result), length
|
||||
|
||||
def ending_options(anchors, plan, choices):
|
||||
if not anchors or plan.target or plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
|
||||
return tuple([anchors])
|
||||
terminal = next((a for a in anchors if a.role == 'terminal'), None)
|
||||
if terminal is None or terminal.hold_until is None:
|
||||
return tuple([anchors])
|
||||
passage_end = plan.passage_end or plan.end
|
||||
active = [c for c in choices if c.scale and c.span.start < passage_end and c.span.end > plan.start]
|
||||
if not active:
|
||||
return tuple([anchors])
|
||||
# Missing harmony is mandatory space. Retiming an ordinary ending must not
|
||||
# move its destination into a trailing N.C. span.
|
||||
harmonic_end = min(passage_end, active[-1].span.end)
|
||||
result = []
|
||||
for release in (F(), F(1, 2), F(1)):
|
||||
finish = harmonic_end - release
|
||||
last = next((c for c in choices if c.span.start < finish <= c.span.end), None)
|
||||
for duration in (F(1, 2), F(1), F(2), F(3)):
|
||||
start = finish - duration
|
||||
if last is None or start < max(last.span.start, plan.passage_start or plan.start) or any(a.role != 'terminal' and a.latest + a.duration > start for a in anchors):
|
||||
continue
|
||||
changed = dataclasses.replace(terminal, earliest=start, latest=start, duration=duration, hold_until=finish)
|
||||
result.append(tuple(changed if a.id == terminal.id else a for a in anchors))
|
||||
return tuple(result) or tuple([anchors])
|
||||
|
||||
def ending_plan(plan, anchors):
|
||||
if plan.target or plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference'):
|
||||
return plan
|
||||
terminal = next((a for a in anchors if a.role == 'terminal'), None)
|
||||
if terminal is None or terminal.hold_until is None:
|
||||
return plan
|
||||
finish = plan.passage_end or plan.end
|
||||
release = finish - terminal.hold_until
|
||||
# The candidate chooses where arrival begins. Earlier portions remain active.
|
||||
spans = []
|
||||
for span in plan.passages:
|
||||
low, high = span.start, min(span.end, terminal.earliest)
|
||||
if low < high:
|
||||
spans.append(dataclasses.replace(span, end=high, intent='active', occupancy=F(str(generator.solo.policy.DEFAULT.active_occupancy))))
|
||||
if not spans and plan.start < terminal.earliest:
|
||||
spans.append(generator.solo.types.ActivitySpan(plan.passage_start if plan.passage_start is not None else plan.start, terminal.earliest, 'active', plan.activity, F(str(generator.solo.policy.DEFAULT.active_occupancy))))
|
||||
arrival_length = terminal.hold_until - terminal.earliest
|
||||
desired = max(F(), plan.activity * (finish - (plan.passage_start if plan.passage_start is not None else plan.start)) - 1)
|
||||
expected = sum(s.activity * (s.end - s.start) for s in spans)
|
||||
spans = [dataclasses.replace(s, activity=min(F(2), s.activity * desired / expected) if expected else plan.activity) for s in spans]
|
||||
spans.append(generator.solo.types.ActivitySpan(terminal.earliest, terminal.hold_until, 'sustained_arrival', 1 / arrival_length, F(str(generator.solo.policy.DEFAULT.arrival_occupancy))))
|
||||
if release:
|
||||
spans.append(generator.solo.types.ActivitySpan(terminal.hold_until, finish, 'space', F(), F()))
|
||||
complete = plan.end == finish
|
||||
return dataclasses.replace(plan, release=release if complete else F(), passages=tuple(spans), ending=('breath' if release else 'sustain') if complete else 'connected')
|
||||
|
||||
def align_anchors(slots, anchors, plan):
|
||||
"""Choose arrival timing with the rhythm, before assigning any free pitches."""
|
||||
slots = list(slots)
|
||||
exact = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
|
||||
protected_end = plan.start + plan.motif.duration if exact else plan.start
|
||||
for anchor in anchors:
|
||||
if not plan.start <= anchor.earliest < plan.end - plan.release:
|
||||
continue
|
||||
possible = [s for s in slots if anchor.earliest <= s.start <= anchor.latest]
|
||||
fit = [s for s in possible if s.end - s.start >= anchor.duration]
|
||||
selected = (fit or possible)
|
||||
slot = selected[0] if selected else None
|
||||
time = slot.start if slot else anchor.earliest
|
||||
end = anchor.hold_until or time + anchor.duration
|
||||
end = min(end, plan.end - plan.release)
|
||||
if time < protected_end:
|
||||
if slot is None or slot.end < end or (slot.forced and slot.forced != anchor.pitch):
|
||||
return None
|
||||
slots[slots.index(slot)] = dataclasses.replace(slot, forced=anchor.pitch)
|
||||
continue
|
||||
group = slot.group if slot else next((s.group for s in reversed(slots) if s.start <= time), 0)
|
||||
event_id = slot.id if slot else anchor.id
|
||||
replacement = Slot(event_id, time, end, group, slot.reference if slot else 0, anchor.pitch)
|
||||
before = [dataclasses.replace(s, end=min(s.end, time)) for s in slots if s.start < time]
|
||||
slots = [s for s in before if s.end > s.start] + [replacement] + [s for s in slots if s.start >= end and s.start != time]
|
||||
return tuple(slots)
|
||||
|
||||
def construct(plan, reference, relationship, template, anchors, variant, choices, octave=0):
|
||||
identifier, pattern, period = template
|
||||
if not pattern or period <= 0:
|
||||
return None
|
||||
finish = plan.end - plan.release
|
||||
slots, groups, cursor = (), [], plan.start
|
||||
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
|
||||
while cursor < finish:
|
||||
index = len(groups)
|
||||
# Establish the idea before departing. Choose from the remaining musical
|
||||
# span instead of cycling a fixed list of transformations indefinitely.
|
||||
remaining = finish - cursor
|
||||
operation = relationship if index == 0 else 'repeat' if index == 1 and variant == 0 else 'answer' if remaining <= period else 'extend' if variant == 1 and index == 1 else 'recall'
|
||||
activity = next((p for p in plan.passages if p.start <= cursor < p.end), None)
|
||||
rate = F(len(pattern), 1) / period
|
||||
unit = F(1) # Rhythmic development is musical intent, never a density repair.
|
||||
local = pattern
|
||||
length = period
|
||||
if index and operation == 'extend':
|
||||
slots, cursor = reopen(slots, cursor)
|
||||
# Continue a recognizable opening beyond the old cadence; this group
|
||||
# can span either side of a barline without a new catalog selection.
|
||||
tail = pattern[:2]
|
||||
local = (*pattern, *((period + a, period + b) for a, b in tail))
|
||||
length += tail[-1][1]
|
||||
elif index and operation == 'answer':
|
||||
local = pattern[:max(2, len(pattern) - 1)]
|
||||
length = local[-1][1]
|
||||
elif index and operation == 'recall':
|
||||
operation = 'repeat_with_harmonic_adaptation'
|
||||
elif index and operation == 'displace':
|
||||
local = tuple((a + F(1, 2), b + F(1, 2)) for a, b in pattern)
|
||||
length += F(1, 2)
|
||||
if index and unit == 2:
|
||||
operation = 'fragment'
|
||||
local = local[:3]
|
||||
length = local[-1][1]
|
||||
if index and not protected:
|
||||
connected = operation not in ('repeat', 'repeat_with_harmonic_adaptation')
|
||||
local, length = develop_timing(local, length, unit, activity.occupancy if activity else F(3, 4), connected)
|
||||
if unit < 1:
|
||||
operation = 'diminish' if operation != 'extend' else 'extend_diminished'
|
||||
elif unit > 1:
|
||||
operation = 'broaden_ending'
|
||||
unit = F(1)
|
||||
if activity and activity.intent == 'space':
|
||||
cursor = min(activity.end, finish)
|
||||
continue
|
||||
if cursor + unit * length > finish:
|
||||
operation = 'close' if index else operation
|
||||
group_end = min(finish, cursor + unit * length)
|
||||
if group_end <= cursor:
|
||||
break
|
||||
group = Group(f'{plan.id}_development_{index + 1}', operation, cursor, group_end, identifier)
|
||||
groups.append(group)
|
||||
new = []
|
||||
for j, (a, b) in enumerate(local):
|
||||
start, end = cursor + a * unit, min(group_end, cursor + b * unit)
|
||||
if start >= end:
|
||||
continue
|
||||
quote = protected and index == 0
|
||||
pitch = None
|
||||
if quote and plan.treatment in ('quote', 'exact_quote'):
|
||||
pitch = dataclasses.replace(reference.notes[j].pitch, octave=reference.notes[j].pitch.octave + octave)
|
||||
new.append(Slot(f'{group.id}_event_{j + 1}', start, end, index, j % len(pattern), pitch, quote))
|
||||
slots = (*slots, *new)
|
||||
cursor = group_end
|
||||
if protected and plan.start + reference.duration > finish:
|
||||
return None
|
||||
aligned = align_anchors(slots, anchors, plan)
|
||||
if aligned is None:
|
||||
return None
|
||||
# Missing harmony is explicit space. Only ordinary material may fragment.
|
||||
filtered = []
|
||||
for slot in aligned:
|
||||
pieces = [(slot.start, slot.end)]
|
||||
for choice in choices:
|
||||
if choice.scale is None:
|
||||
pieces = [(a, b) for low, high in pieces for a, b in ((low, min(high, choice.span.start)), (max(low, choice.span.end), high)) if b > a]
|
||||
if slot.protected and pieces != [(slot.start, slot.end)]:
|
||||
return None
|
||||
filtered.extend(dataclasses.replace(slot, id=slot.id + (f'_fragment_{i}' if len(pieces) > 1 else ''), start=a, end=b) for i, (a, b) in enumerate(pieces))
|
||||
# The finalized rhythm is the contract, including changes made by extension
|
||||
# and anchor alignment. It is never rebased when a cell or bar ends.
|
||||
identity = generator.solo.policy.stable_rank(plan.seed, (identifier, variant, anchors, relationship, octave))[:12]
|
||||
return RhythmDraft(identity, tuple(filtered), tuple(groups), anchors, reference, relationship, octave)
|
||||
|
||||
def rhythms(plan, incoming, measures, choices, library, batch=0, reference_library=()):
|
||||
continuing = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
|
||||
anchors = (incoming.selected_anchors,) if continuing and incoming.selected_anchors else plan.anchor_options or tuple([()])
|
||||
references = [(plan.motif, 'statement')]
|
||||
if incoming.home and plan.treatment not in ('quote', 'exact_quote', 'rhythmic_reference'):
|
||||
references = [(incoming.home, 'continue' if incoming.ending == 'connected' or continuing else 'answer')]
|
||||
if not continuing:
|
||||
references.append((incoming.home, 'recall'))
|
||||
if plan.motif.id != incoming.home.parent:
|
||||
references.append((plan.motif, 'contrast'))
|
||||
if incoming.themes and incoming.themes[0].id != incoming.home.id:
|
||||
references.append((incoming.themes[0], 'recall'))
|
||||
if plan.treatment not in ('quote', 'exact_quote', 'rhythmic_reference') and not continuing:
|
||||
references.extend((motif, 'contrast' if incoming.home else 'statement') for example in reference_library for motif in example.windows())
|
||||
elapsed = F()
|
||||
meter = measures[0]['time_signature']
|
||||
for measure in measures:
|
||||
end = elapsed + generator.model.fraction(measure['duration'])
|
||||
if elapsed <= plan.start < end:
|
||||
meter = measure['time_signature']
|
||||
break
|
||||
elapsed = end
|
||||
result = []
|
||||
for reference, relationship in references:
|
||||
available_templates = templates(plan, reference, library, meter)
|
||||
if isinstance(reference, generator.solo.types.RealizedMotif) or reference.origin == 'accepted reference profile':
|
||||
available_templates = tuple([(reference.id, tuple((n.start, n.end) for n in reference.notes), reference.duration)])
|
||||
for template in available_templates:
|
||||
for backbone in anchors:
|
||||
endings = (backbone,) if continuing else ending_options(backbone, plan, choices)
|
||||
for ending in endings:
|
||||
for variant in range(2):
|
||||
for octave in ((0, 1, -1, 2, -2) if plan.treatment in ('quote', 'exact_quote') else tuple([0])):
|
||||
rhythm = construct(ending_plan(plan, ending), reference, relationship, template, ending, variant, choices, octave)
|
||||
if rhythm:
|
||||
result.append(rhythm)
|
||||
|
||||
def rank(rhythm):
|
||||
expected = float(plan.activity * (plan.end - plan.start))
|
||||
# Broad rhythm feasibility only. Pitch/rhythm relationships are evaluated
|
||||
# after realization; one attack cannot eliminate an otherwise useful tune.
|
||||
broad = abs(len(rhythm.slots) - expected) > max(4, expected / 2)
|
||||
return broad, generator.solo.policy.stable_rank(plan.seed, rhythm.id)
|
||||
|
||||
def family(rhythm):
|
||||
pitches = tuple(a.pitch.number() for a in rhythm.anchors)
|
||||
shape = generator.solo.sequences.family(tuple(b - a for a, b in zip(pitches, pitches[1:])))
|
||||
return rhythm.relationship, rhythm.groups[0].source, shape
|
||||
|
||||
limit = generator.solo.policy.DEFAULT.draft_frontier
|
||||
return generator.solo.sequences.diverse(result, rank, family, limit * (batch + 1))[limit * batch:]
|
||||
|
||||
def canonical_context(notes, incoming, plan):
|
||||
prefix = incoming.notes if incoming.passage_id == plan.passage_id and plan.passage_id else ((incoming.last,) if incoming.last else ())
|
||||
return generator.solo.types.sounding_notes((*prefix, *notes))
|
||||
|
||||
def local_sounding(notes):
|
||||
if notes and notes[0].continuation:
|
||||
notes = (dataclasses.replace(notes[0], continuation=False), *notes[1:])
|
||||
return generator.solo.types.sounding_notes(notes)
|
||||
|
||||
def contract_valid(draft, notes):
|
||||
by_id = {n.event_id: n for n in local_sounding(notes)}
|
||||
for tail in draft.sustained_tails:
|
||||
if not any(n.pitch.number() == tail.pitch and n.start <= tail.start and n.end >= tail.end for n in by_id.values()):
|
||||
return False, f'{tail.start}-{tail.end}: required sounding arrival tail changed'
|
||||
original = {n.event_id: n for n in draft.notes}
|
||||
for identity in (*draft.structural, *draft.protected):
|
||||
before, after = original[identity], by_id.get(identity)
|
||||
if after is None or (before.start, before.end, before.pitch) != (after.start, after.end, after.pitch):
|
||||
return False, f'{identity}: protected draft event changed'
|
||||
for transform in draft.transformations:
|
||||
if not transform.preserves:
|
||||
continue
|
||||
core = [by_id.get(identity) for identity in transform.events[:min(3, len(transform.events))]]
|
||||
if not all(core):
|
||||
return False, f'{transform.id}: identifying events missing'
|
||||
if 'rhythm' in transform.preserves and tuple((n.start - transform.start, n.end - transform.start) for n in core) != transform.rhythm[:len(core)]:
|
||||
return False, f'{transform.id}: identifying rhythm changed'
|
||||
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(core, core[1:]))
|
||||
if 'opening_contour' in transform.preserves and directions != transform.directions[:len(directions)]:
|
||||
return False, f'{transform.id}: identifying contour changed'
|
||||
for feature in draft.features:
|
||||
if generator.solo.features.feature_error(feature, local_sounding(notes)):
|
||||
return False, f'{feature.id}: selected identifying relationship changed'
|
||||
for first, repeated in draft.correspondence:
|
||||
if first not in by_id or repeated not in by_id:
|
||||
return False, 'lead statement/return correspondence was lost'
|
||||
return True, ''
|
||||
|
||||
def relationship_features(draft, notes):
|
||||
"""Inspect relationships once, on canonical events, independent of cells."""
|
||||
notes = local_sounding(notes)
|
||||
by_id = {n.event_id: n for n in notes}
|
||||
preservation = sum(identity not in by_id for identity in draft.structural) / max(1, len(draft.structural))
|
||||
direction, development = [], []
|
||||
for transform in draft.transformations:
|
||||
group = [by_id[i] for i in transform.events if i in by_id]
|
||||
actual = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(group, group[1:]))
|
||||
direction.extend(a != b for a, b in zip(actual, transform.directions))
|
||||
reference = draft.reference_motif if transform.operation in ('statement', 'contrast') else draft.home
|
||||
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.features.relationship(group, reference))
|
||||
joins = []
|
||||
for time in draft.joins:
|
||||
left = next((n for n in reversed(notes) if n.start < time), None)
|
||||
right = next((n for n in notes if n.start >= time), None)
|
||||
if left and right:
|
||||
original_left = next((n for n in reversed(draft.notes) if n.start < time), None)
|
||||
original_right = next((n for n in draft.notes if n.start >= time), None)
|
||||
planned_gap = max(F(), original_right.start - original_left.end) if original_left and original_right else F()
|
||||
gap = max(F(), right.start - left.end - planned_gap)
|
||||
joins.append(min(1, float(gap) / 2) + min(1, max(0, abs(right.pitch.number() - left.pitch.number()) - 4) / 12))
|
||||
terminal = next((a for a in draft.anchors if a.role == 'terminal' and a.earliest < draft.end), None)
|
||||
integration = 0.0
|
||||
if terminal:
|
||||
arrival = generator.solo.harmonic.anchor_note(terminal, notes)
|
||||
before = next((n for n in reversed(notes) if arrival and n.start < arrival.start), None)
|
||||
integration = min(1, max(0, abs(arrival.pitch.number() - before.pitch.number()) - 4) / 12) if arrival and before else 0
|
||||
return {'draft_preservation': preservation, 'development': sum(development) / max(1, len(development)), 'direction': sum(direction) / max(1, len(direction)), 'join_coherence': sum(joins) / max(1, len(joins)), 'ending_integration': integration}
|
||||
@@ -1,204 +0,0 @@
|
||||
"""Bounded target-driven ornaments after strict thematic pair selection.
|
||||
|
||||
The main tune and its home identity remain strictly mapped. Every exception is
|
||||
an atomic upper/lower/target gesture, never a replacement policy for single notes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import math
|
||||
import time
|
||||
from fractions import Fraction as F
|
||||
|
||||
import generator.model
|
||||
from generator.solo import types, harmonic, placement, policy, drafts, routes, phrasing
|
||||
|
||||
|
||||
def mapped_pitch(number, start, end, harmony):
|
||||
if number%12 not in types.permitted(harmony.choices,start,end):return None
|
||||
span=harmony.at(start)
|
||||
return types.scale_pitch(span.scale,types.scale_index(span.scale,number))
|
||||
|
||||
|
||||
def approach(target, harmony):
|
||||
"""Spelling follows the sounding collection, or explicitly spells resolution."""
|
||||
t=target.start; number=target.pitch.number()
|
||||
upper=next((p for n in range(number+1,number+4) if (p:=mapped_pitch(n,t-1,t-F(1,2),harmony))),None)
|
||||
if upper is None:return None
|
||||
lower=mapped_pitch(number-1,t-F(1,2),t,harmony)
|
||||
if lower is None:
|
||||
if target.pitch.alter>0:
|
||||
lower=dataclasses.replace(target.pitch,alter=target.pitch.alter-1)
|
||||
else:
|
||||
letters='cdefgab'; index=letters.index(target.pitch.letter)
|
||||
letter=letters[(index-1)%7]; octave=target.pitch.octave-(index==0)
|
||||
alter=number-1-generator.model.NATURAL[letter]-12*(octave+1)
|
||||
lower=types.Pitch(letter,F(alter),octave)
|
||||
if not all(placement.positions(p.number(),harmony.profile) for p in (upper,lower)):return None
|
||||
identity=target.event_id+':enclosure'
|
||||
return (types.Note(t-1,t-F(1,2),upper,target.sources,'enclosure_upper',event_id=identity+':upper'),
|
||||
types.Note(t-F(1,2),t,lower,target.sources,'enclosure_lower',event_id=identity+':lower'))
|
||||
|
||||
|
||||
def declared_targets(phrase,harmony):
|
||||
"""Declare eligible existing stops before proposing their ornaments."""
|
||||
if not phrase.draft:return ()
|
||||
terminal={a.pitch.number():a for a in phrase.anchors if a.role=='terminal'}
|
||||
values=[]
|
||||
held=phrase.draft.notes[0].event_id if phrase.draft.notes and phrase.draft.notes[0].continuation else None
|
||||
for note in drafts.local_sounding(phrase.draft.notes):
|
||||
if note.event_id==held:continue
|
||||
if note.continuation or note.role=='quote' or note.end-note.start<1:continue
|
||||
if note.pitch.number()%12 not in harmony.members(note.start,note.end):continue
|
||||
anchor=terminal.get(note.pitch.number())
|
||||
role='terminal' if anchor and anchor.earliest<=note.start<=anchor.latest else 'intermediate'
|
||||
rank=(role!='terminal',phrasing.destination_cost(note.pitch,note.start,note.end,harmony,terminal=role=='terminal'),-(note.end-note.start),note.start)
|
||||
values.append((rank,note,role))
|
||||
return tuple((n,role) for _,n,role in sorted(values)[:policy.DEFAULT.enclosure_targets])
|
||||
|
||||
|
||||
def replace_window(notes,start,end,replacement):
|
||||
"""Only rewrite continuously sounding material; preserve meaningful space."""
|
||||
cursor=start;prefix=[];suffix=[]
|
||||
for note in notes:
|
||||
if note.end<=start:prefix.append(note);continue
|
||||
if note.start>=end:suffix.append(note);continue
|
||||
if note.start>cursor:return None
|
||||
cursor=max(cursor,note.end)
|
||||
if note.start<start:prefix.append(dataclasses.replace(note,end=start))
|
||||
if note.end>end:return None # never clip a pre-existing target sustain
|
||||
if cursor<end:return None
|
||||
return (*prefix,*replacement,*suffix)
|
||||
|
||||
|
||||
def editable(draft,start,end):
|
||||
ids=set((*draft.structural,*draft.protected,*(e for f in draft.features for e in f.events)))
|
||||
return not any(n.start<end and n.end>start and (n.event_id in ids or n.role=='quote') for n in draft.notes) and not any(t.start<end and t.end>start for t in draft.sustained_tails)
|
||||
|
||||
|
||||
def movement(notes):
|
||||
return sum(max(0,abs(b.pitch.number()-a.pitch.number())-3)/12 for a,b in zip(notes,notes[1:]))/max(1,len(notes)-1)
|
||||
|
||||
|
||||
def candidates(phrase,harmony,library,tempo,rejections):
|
||||
draft=phrase.draft
|
||||
if not draft:return ()
|
||||
if draft.notes and draft.notes[0].continuation:
|
||||
# This local budget does not reopen a committed preceding phrase. Never
|
||||
# invent a new attack by detaching its already-sounding entrance.
|
||||
rejections.append({'target':draft.notes[0].event_id,'reason':'cross-phrase sustain retained; no local rewrite of its context'})
|
||||
return ()
|
||||
notes=types.sounding_notes(draft.notes);result=[]
|
||||
for target,role in declared_targets(phrase,harmony):
|
||||
start=target.start-1
|
||||
if start<phrase.plan.start or not editable(draft,start,target.start):
|
||||
rejections.append({'target':target.event_id,'reason':'protected material or no editable approach window'});continue
|
||||
pair=approach(target,harmony)
|
||||
if not pair:
|
||||
rejections.append({'target':target.event_id,'reason':'no mapped upper neighbor or playable approach'});continue
|
||||
upper,lower=pair
|
||||
contract=types.EnclosureContract(target.event_id+':enclosure',upper,lower,target,harmony.at(target.start).chord,role,
|
||||
types.SustainedTail(target.start,target.end,target.pitch.number()))
|
||||
proposals=[(start,pair,None)]
|
||||
# Reopen up to two beats before the approach as a whole in-map route. Keep
|
||||
# the incoming attack and fixed upper endpoint, not independently chosen steps.
|
||||
for first in reversed(notes):
|
||||
if not start-2<=first.start<start or first.end>start or not editable(draft,first.end,target.start):continue
|
||||
templates=tuple(n for n in notes if first.start<=n.start<start)
|
||||
if not templates or any(a.end!=b.start for a,b in zip(templates,templates[1:])) or templates[-1].end!=start:continue
|
||||
for route in routes.candidates((*templates,upper),{upper.event_id},harmony,library,(41,76),'mapped approach into enclosure')[:policy.DEFAULT.enclosure_routes]:
|
||||
proposals.append((first.start,(*route.notes,lower),route))
|
||||
break
|
||||
accepted=0
|
||||
for begin,replacement,route in proposals[:1+policy.DEFAULT.enclosure_routes]:
|
||||
actual=replace_window(notes,begin,target.start,replacement)
|
||||
if actual is None:continue
|
||||
variants=[(actual,contract)]
|
||||
upper_index=next((i for i,n in enumerate(actual) if n.event_id==upper.event_id),0)
|
||||
if upper_index:
|
||||
prior=actual[upper_index-1]
|
||||
if prior.end==upper.start and prior.pitch==upper.pitch and editable(draft,prior.start,upper.start):
|
||||
shared=dataclasses.replace(prior,end=upper.end)
|
||||
combined=(*actual[:upper_index-1],shared,*actual[upper_index+1:])
|
||||
variants.append((combined,dataclasses.replace(contract,upper=shared,approach_start=upper.start)))
|
||||
for actual,actual_contract in variants:
|
||||
valid,error=drafts.contract_valid(draft,actual)
|
||||
if not valid:continue
|
||||
checked=harmony.evaluate(actual,phrase.state.treatments if phrase.state else (),until=phrase.plan.end,complete=True,enclosures=(actual_contract,))
|
||||
if not checked.valid:continue
|
||||
valid,error=harmonic.anchors_valid(phrase.anchors,actual,phrase.plan.end,complete=True,closure=phrase.plan.closure)
|
||||
if not valid:continue
|
||||
placed=placement.place(actual,tempo,profile=harmony.profile)
|
||||
if placed is None:continue
|
||||
old_travel=placement.place(notes,tempo,profile=harmony.profile)[0]
|
||||
delta=.6*(movement(actual)-movement(notes))+.025*(placed[0]-old_travel)/max(1,len(notes))/2000
|
||||
density=4*len(actual)/float(phrase.plan.end-phrase.plan.start)
|
||||
before_density=4*len(notes)/float(phrase.plan.end-phrase.plan.start)
|
||||
repeated=sum(a.pitch.number()==b.pitch.number() and a.end==b.start for a,b in zip(actual,actual[1:]))
|
||||
old_repeated=sum(a.pitch.number()==b.pitch.number() and a.end==b.start for a,b in zip(notes,notes[1:]))
|
||||
delta+=.15*max(0,repeated-old_repeated)
|
||||
delta+=.1*(max(0,density-6)-max(0,before_density-6))
|
||||
transforms=tuple(dataclasses.replace(t,events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
|
||||
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end)) for t in draft.transformations)
|
||||
changed=dataclasses.replace(draft,notes=actual,transformations=transforms,enclosures=(actual_contract,))
|
||||
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in actual if d.start<=n.start<d.end)) for d in phrase.decisions)
|
||||
if tuple(n for d in decisions for n in d.notes)!=actual:continue
|
||||
retained=tuple(r for r in phrase.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 actual) for n in r.notes))
|
||||
result.append((delta,dataclasses.replace(phrase,draft=changed,decisions=decisions,before_enclosure=draft,
|
||||
routes=(*retained,*((route,) if route else ())))))
|
||||
accepted+=1
|
||||
if not accepted:rejections.append({'target':target.event_id,'reason':'no valid complete enclosure within local route budget'})
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def strict(solo):
|
||||
"""Recover the exact selected pre-enclosure notes, including their placement."""
|
||||
phrases=[]
|
||||
for phrase in solo.phrases:
|
||||
draft=phrase.before_enclosure
|
||||
if not draft:phrases.append(phrase);continue
|
||||
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in draft.notes if d.start<=n.start<d.end)) for d in phrase.decisions)
|
||||
state=dataclasses.replace(phrase.state,notes=draft.notes,last=draft.notes[-1] if draft.notes else phrase.state.last) if phrase.state else None
|
||||
phrases.append(dataclasses.replace(phrase,draft=draft,decisions=decisions,state=state))
|
||||
return dataclasses.replace(solo,phrases=tuple(phrases),diagnostics={**solo.diagnostics,'enclosures_enabled':False})
|
||||
|
||||
|
||||
def refine(solo,library,enabled=True):
|
||||
started=time.monotonic();harmony=harmonic.Harmony(solo.choices,solo.profile);rejections=[]
|
||||
baseline=[]
|
||||
for phrase in solo.phrases:
|
||||
draft=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes)) if phrase.draft else None
|
||||
baseline.append(dataclasses.replace(phrase,draft=draft,before_enclosure=draft))
|
||||
baseline=tuple(baseline)
|
||||
if not enabled:
|
||||
return dataclasses.replace(solo,phrases=baseline,diagnostics={**solo.diagnostics,'enclosures_enabled':False,'enclosure_seconds':time.monotonic()-started})
|
||||
maximum=math.ceil(policy.DEFAULT.enclosure_max_per_32_bars*float(solo.end)/128)
|
||||
low,high=2*float(solo.end)/128,3*float(solo.end)/128
|
||||
states=[(0.,(),())];options_count=0
|
||||
def sparse(count):return .10*max(0,low-count)+.12*max(0,count-high)
|
||||
for phrase in baseline:
|
||||
options=((0.,phrase),*candidates(phrase,harmony,library,solo.tempo,rejections));options_count+=len(options)-1
|
||||
following=[]
|
||||
for cost,chosen,arrivals in states:
|
||||
for delta,option in options:
|
||||
enclosure=option.draft.enclosures[0] if option.draft and option.draft.enclosures else None
|
||||
if enclosure and len(arrivals)>=maximum:continue
|
||||
spacing=.08*max(0,1-float(enclosure.target.start-arrivals[-1][0])/16) if enclosure and arrivals else 0.
|
||||
repeated=.06*sum(pc==enclosure.target.pitch.number()%12 for _,pc in arrivals) if enclosure else 0.
|
||||
history=(*arrivals,(enclosure.target.start,enclosure.target.pitch.number()%12)) if enclosure else arrivals
|
||||
following.append((cost+delta+spacing+repeated,(*chosen,option),history))
|
||||
states=sorted(following,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2])))[:policy.DEFAULT.enclosure_frontier]
|
||||
_,phrases,arrivals=min(states,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2])))
|
||||
# Resolve the complete guitar path once, including both sides of phrase joins.
|
||||
raw=tuple(n for p in phrases for d in p.decisions for n in d.notes)
|
||||
placed=placement.place(raw,solo.tempo,profile=harmony.profile)
|
||||
if placed is None:raise ValueError('selected enclosure solo has no complete guitar path')
|
||||
by_id={n.event_id:n for n in placed[1]};final=[]
|
||||
for phrase in phrases:
|
||||
decisions=tuple(dataclasses.replace(d,notes=tuple(by_id[n.event_id] for n in d.notes)) for d in phrase.decisions)
|
||||
notes=tuple(n for d in decisions for n in d.notes)
|
||||
state=dataclasses.replace(phrase.state,notes=notes,last=notes[-1] if notes else phrase.state.last) if phrase.state else None
|
||||
final.append(dataclasses.replace(phrase,decisions=decisions,draft=dataclasses.replace(phrase.draft,notes=notes) if phrase.draft else None,state=state))
|
||||
diagnostics={**solo.diagnostics,'enclosures_enabled':True,'enclosure_seconds':time.monotonic()-started,
|
||||
'enclosure_candidates':options_count,'enclosure_rejections':rejections,'enclosures_selected':len(arrivals)}
|
||||
return dataclasses.replace(solo,phrases=tuple(final),diagnostics=diagnostics)
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
from generator import harmony as chord_model, model, pitch as spelling, vocabulary
|
||||
from generator.solo import types, drafts, enclosures, placement, policy
|
||||
from generator.solo import types
|
||||
|
||||
|
||||
def quality(chord):
|
||||
@@ -158,177 +158,3 @@ def validate_frontier(notes,contracts,harmony,until,complete):
|
||||
if incomplete:pending.append(c)
|
||||
approved,error=validate(tuple(sorted(future.values(),key=lambda n:(n.start,n.event_id))),contracts,harmony)
|
||||
return {key:c for key,c in approved.items() if key in actual},tuple(pending),error
|
||||
|
||||
|
||||
def proposals(phrase,harmony):
|
||||
draft=phrase.draft
|
||||
if not draft or any(n.continuation for n in draft.notes):return
|
||||
notes=types.sounding_notes(draft.notes)
|
||||
for target,role in enclosures.declared_targets(phrase,harmony):
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
start=target.start-F(rule['gap'])-sum((F(d) for d in rule['durations']),F())
|
||||
if start<phrase.plan.start or not enclosures.editable(draft,start,target.start):continue
|
||||
cursor=start;events=[]
|
||||
for i,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])):
|
||||
events.append(types.Note(cursor,cursor+F(duration),chromatic(target.pitch,step),target.sources,rule['kind'],event_id=target.event_id+':'+rule['id']+':'+str(i)))
|
||||
cursor+=F(duration)
|
||||
actual=enclosures.replace_window(notes,start,target.start,tuple(events))
|
||||
if actual is None:continue
|
||||
prior=next((n for n in reversed(actual) if n.end<=start),None)
|
||||
contract=types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(events),target,harmony.at(target.start).chord,preparation=prior)
|
||||
yield actual,contract
|
||||
for i,n in enumerate(notes[:-1]):
|
||||
target=notes[i+1]
|
||||
if not enclosures.editable(draft,n.start,n.end):continue
|
||||
span=harmony.at(n.start)
|
||||
if not span or not span.scale:continue
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if not eligible_color(rule,span,harmony) or n.end-n.start>F(rule['max_beats']):continue
|
||||
p=degree_pitch(chord_model.parse_symbol(span.chord),rule['degree'],rule['alter'],n.pitch.number())
|
||||
if abs(p.number()-n.pitch.number())>4 or p==n.pitch:continue
|
||||
color=dataclasses.replace(n,pitch=p,role='color')
|
||||
resolution=target if rule.get('resolve') else color
|
||||
contract=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(color,),resolution,harmony.at(resolution.start).chord)
|
||||
yield (*notes[:i],color,*notes[i+1:]),contract
|
||||
for rule in vocabulary.data('lead_ins')['blues']:
|
||||
if i==0 or n.end-n.start>F(1,2):continue
|
||||
p=degree_pitch(chord_model.parse_symbol(span.chord),rule['degree'],rule['alter'],n.pitch.number())
|
||||
changed=dataclasses.replace(n,pitch=p,role='blues')
|
||||
yield (*notes[:i],changed,*notes[i+1:]),types.ExpressionContract(n.event_id+':'+rule['id'],'blues',rule['id'],(changed,),target,harmony.at(target.start).chord,preparation=notes[i-1])
|
||||
for i,target in enumerate(notes):
|
||||
if i==0 or target.start not in {s.start for s in harmony.spans}:continue
|
||||
start=target.start-F(1,2)
|
||||
if not enclosures.editable(draft,start,target.start):continue
|
||||
n=dataclasses.replace(target,start=start,role='anticipation')
|
||||
actual=enclosures.replace_window(notes,start,target.start,())
|
||||
if actual:
|
||||
actual=tuple(n if a.event_id==target.event_id else a for a in actual)
|
||||
yield actual,types.ExpressionContract(target.event_id+':anticipate','anticipation','early_chord_tone',(n,),n,harmony.at(target.start).chord,boundary=target.start)
|
||||
for i,(before,target) in enumerate(zip(notes,notes[1:])):
|
||||
boundary=target.start
|
||||
if before.end!=boundary or boundary not in {s.start for s in harmony.spans}:continue
|
||||
if not enclosures.editable(draft,before.start,target.end):continue
|
||||
for rule in vocabulary.data('harmony').get('suspensions',[]):
|
||||
delay=min(F(1,2),F(rule['max_delay']))
|
||||
if target.end<=boundary+delay:continue
|
||||
held=dataclasses.replace(before,end=boundary+delay,role='suspension')
|
||||
resolved=dataclasses.replace(target,start=boundary+delay)
|
||||
yield (*notes[:i],held,resolved,*notes[i+2:]),types.ExpressionContract(before.event_id+':suspend',
|
||||
'suspension',rule['id'],(held,),resolved,harmony.at(boundary).chord,boundary=boundary)
|
||||
|
||||
|
||||
def joint_candidates(phrases,incoming,harmony,tempo,context,*,bar=F(4)):
|
||||
"""Expression alternatives compete BEFORE complete-phrase selection.
|
||||
|
||||
This rewrites provisional gestures, never the selected chorus. The direct
|
||||
realization remains available and every alternative uses the same evaluator.
|
||||
"""
|
||||
from generator.solo import realization, sequences, phrasing, groups
|
||||
result=list(phrases)
|
||||
trace=context.diagnostics.setdefault('expression_operations',{})
|
||||
for phrase in phrases:
|
||||
proposed={}
|
||||
for actual,c in proposals(phrase,harmony):
|
||||
counts=trace.setdefault(c.kind,dict(proposed=0,valid=0,retained=0,rejected_by_rule=0))
|
||||
counts['proposed']+=1
|
||||
old_pattern=tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in phrase.draft.notes)
|
||||
new_pattern=tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in actual)
|
||||
if groups.valid(old_pattern) and not groups.valid(new_pattern):
|
||||
counts['rejected_by_rule']+=1;continue
|
||||
if not drafts.contract_valid(phrase.draft,actual)[0]:
|
||||
counts['rejected_by_rule']+=1;continue
|
||||
checked=harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(*phrase.draft.expressions,c))
|
||||
if not checked.valid:
|
||||
counts['rejected_by_rule']+=1;continue
|
||||
counts['valid']+=1
|
||||
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
|
||||
join=abs(prior.pitch.number()-c.events[0].pitch.number()) if prior else 0
|
||||
rank=max(0,join-5)+bool(prior and prior.pitch==c.events[0].pitch)
|
||||
proposed.setdefault(c.kind,[]).append((rank,actual,c))
|
||||
# One representative per treatment kind, then a bounded second sweep.
|
||||
options=sequences.diverse([v for values in proposed.values() for v in values],
|
||||
lambda v:(v[0],policy.stable_rank(phrase.plan.seed,v[2].id)),lambda v:v[2].kind,8)
|
||||
for _,actual,c in options:
|
||||
transforms=tuple(dataclasses.replace(t,
|
||||
events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
|
||||
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end))
|
||||
for t in phrase.draft.transformations)
|
||||
changed=dataclasses.replace(phrase.draft,id=phrase.draft.id+':'+c.rule,
|
||||
notes=actual,expressions=(*phrase.draft.expressions,c),transformations=transforms)
|
||||
candidate=realization.checked_phrase(phrase.plan,changed,actual,incoming.treatments,incoming,harmony,tempo,phrase.edits)
|
||||
if candidate is None:continue
|
||||
trace[c.kind]['retained']+=1
|
||||
state=dataclasses.replace(phrase.state,notes=candidate.state.notes,last=candidate.state.last,
|
||||
motifs=candidate.state.motifs,travel=candidate.state.travel)
|
||||
history=incoming.history
|
||||
for t in transforms:
|
||||
ns=tuple(n for n in actual if n.event_id in t.events)
|
||||
if ns:history=phrasing.remember(history,phrasing.heard(ns,t.operation,bar=bar),bar)
|
||||
state=dataclasses.replace(state,history=history)
|
||||
kept_routes=tuple(r for r in phrase.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 actual) for n in r.notes))
|
||||
original=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes))
|
||||
result.append(dataclasses.replace(candidate,state=state,initial=phrase.initial,
|
||||
refined=phrase.refined,before_rhythm=phrase.before_rhythm,before_enclosure=original,routes=kept_routes))
|
||||
return result
|
||||
|
||||
|
||||
def refine(solo,enabled=True):
|
||||
from generator.solo import harmonic
|
||||
harmony=harmonic.Harmony(solo.choices,solo.profile);result=[];history=[];attempts=0
|
||||
seed=vocabulary.role(solo.part+'_solo').get('choice_seed',solo.part)
|
||||
for phrase in solo.phrases:
|
||||
draft=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes))
|
||||
phrase=dataclasses.replace(phrase,draft=draft,before_enclosure=draft)
|
||||
options=[(0.,phrase,None)]
|
||||
if enabled:
|
||||
original=draft.notes
|
||||
for actual,c in proposals(phrase,harmony):
|
||||
attempts+=1
|
||||
if not drafts.contract_valid(draft,actual)[0]:continue
|
||||
if not harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(c,)).valid:continue
|
||||
if not harmonic.anchors_valid(phrase.anchors,actual,phrase.plan.end,complete=True,closure=phrase.plan.closure)[0]:continue
|
||||
placed=placement.place(actual,solo.tempo,profile=solo.profile)
|
||||
if not placed:continue
|
||||
# Context, repetition, and motion determine utility; no chromatic count target.
|
||||
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
|
||||
join=abs(prior.pitch.number()-c.events[0].pitch.number()) if prior else 0
|
||||
duplicated=bool(prior and prior.pitch==c.events[0].pitch)
|
||||
destination=c.target.pitch.number()
|
||||
old_approach=next((n for n in reversed(original) if n.end<=c.target.start),None)
|
||||
prepares=bool(old_approach and abs(old_approach.pitch.number()-destination)>2 and join<=3)
|
||||
recent=sum(kind==c.kind for kind in history[-2:])
|
||||
cost=.08*max(0,join-3)+.18*duplicated+.12*recent+.3*(enclosures.movement(actual)-enclosures.movement(original))-.12*prepares
|
||||
if c.kind=='anticipation':cost-=.05
|
||||
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in actual if d.start<=n.start<d.end)) for d in phrase.decisions)
|
||||
if tuple(n for d in decisions for n in d.notes)!=actual:continue
|
||||
# Rebase editable development spans onto the completed gesture. The
|
||||
# feature contract is unchanged, but stale event IDs/rhythm would make
|
||||
# reports falsely describe notes that the expression replaced.
|
||||
transforms=tuple(dataclasses.replace(t,
|
||||
events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
|
||||
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end))
|
||||
for t in draft.transformations)
|
||||
changed=dataclasses.replace(draft,notes=actual,expressions=(*phrase.draft.expressions,c),transformations=transforms)
|
||||
routes=tuple(r for r in phrase.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 actual) for n in r.notes))
|
||||
options.append((cost,dataclasses.replace(phrase,draft=changed,decisions=decisions,routes=routes),c))
|
||||
_,chosen,c=min(options,key=lambda v:(round(v[0],8),policy.stable_rank(seed,(phrase.plan.id,v[2]))))
|
||||
if c:history.append(c.kind)
|
||||
result.append(chosen)
|
||||
raw=tuple(n for p in result for d in p.decisions for n in d.notes)
|
||||
placed=placement.place(raw,solo.tempo,profile=solo.profile)
|
||||
if raw and placed is None:raise ValueError('Expression realization has no complete guitar path')
|
||||
by_id={n.event_id:n for n in placed[1]} if placed else {}
|
||||
final=[]
|
||||
for p in result:
|
||||
decisions=tuple(dataclasses.replace(d,notes=tuple(by_id[n.event_id] for n in d.notes)) for d in p.decisions)
|
||||
notes=tuple(n for d in decisions for n in d.notes)
|
||||
state=dataclasses.replace(p.state,notes=notes,last=notes[-1] if notes else p.state.last) if p.state else None
|
||||
# A legal timing revision preserves the complete route with its new times.
|
||||
routes=[]
|
||||
for route in p.routes:
|
||||
if all(n.event_id in by_id and by_id[n.event_id].pitch==n.pitch for n in route.notes):
|
||||
route_notes=tuple(by_id[n.event_id] for n in route.notes)
|
||||
routes.append(dataclasses.replace(route,notes=route_notes,destination=route_notes[-1]))
|
||||
final.append(dataclasses.replace(p,decisions=decisions,draft=dataclasses.replace(p.draft,notes=notes),state=state,routes=tuple(routes)))
|
||||
return dataclasses.replace(solo,phrases=tuple(final),diagnostics={**solo.diagnostics,'expression_candidates':attempts,'expressions_selected':history,'enclosures_enabled':enabled})
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Pure musical contracts; no reference files or filesystem access."""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import fractions
|
||||
import generator.solo.types as types
|
||||
F = fractions.Fraction
|
||||
|
||||
def identifying_features(notes, source):
|
||||
"""Select a characteristic leap followed by return, wherever it occurs."""
|
||||
notes = tuple(notes)
|
||||
candidates = []
|
||||
for i in range(len(notes) - 2):
|
||||
a, b, c = notes[i:i + 3]
|
||||
leap, reply = b.pitch.number() - a.pitch.number(), c.pitch.number() - b.pitch.number()
|
||||
if 3 <= abs(leap) <= 9 and leap * reply < 0 and c.start - a.start <= 4:
|
||||
group = notes[i:min(len(notes), i + 4)]
|
||||
rhythm = tuple((n.start - a.start, n.end - n.start) for n in group)
|
||||
intervals = tuple(y.pitch.number() - x.pitch.number() for x, y in zip(group, group[1:]))
|
||||
candidates.append(types.IdentifyingFeature(a.event_id + '_identity', tuple(n.event_id for n in group), rhythm, intervals, source))
|
||||
return tuple(candidates[:1])
|
||||
|
||||
def feature_error(feature, notes):
|
||||
by_id = {n.event_id: n for n in notes}
|
||||
actual = [by_id.get(identity) for identity in feature.events]
|
||||
if not actual or not all(actual):
|
||||
return 1.0
|
||||
first = actual[0].start
|
||||
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:]))
|
||||
errors = []
|
||||
if 'rhythm' in feature.preserves:
|
||||
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):
|
||||
"""One joint relationship cost: timed movement, not absolute-pitch copying."""
|
||||
notes = tuple(notes)
|
||||
if len(notes) < 2 or len(reference.notes) < 2:
|
||||
return 0.0 if len(notes) == len(reference.notes) == 1 else 1.0
|
||||
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
|
||||
expected = reference.contour()
|
||||
count = min(len(actual), len(expected))
|
||||
contour = sum(0.75 * (((a > 0) - (a < 0)) != ((b > 0) - (b < 0))) + 0.25 * min(1, max(0, abs(a - b) - 2) / 7) for a, b in zip(actual, expected)) / max(1, count)
|
||||
start, ref_start = notes[0].start, reference.notes[0].start
|
||||
rhythm = sum(min(1, float(abs((n.start - start) - (r.start - ref_start))) / 2) for n, r in zip(notes, reference.notes)) / min(len(notes), len(reference.notes))
|
||||
evidence = max(0, min(3, len(reference.notes)) - len(notes)) / 3
|
||||
return max(evidence, (contour + rhythm) / 2)
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Bounded timing revisions of complete realized gestures, with fixed pitches."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
|
||||
from generator.solo import drafts, harmonic, types, groups
|
||||
|
||||
|
||||
def join_variants(draft,harmony,library):
|
||||
"""Recompose both sides of an editable internal join as one complete route.
|
||||
|
||||
Keep endpoints and exact span. A provisional held tail can reopen into two
|
||||
binary attacks; a committed hold or identifying event is never consumed.
|
||||
Alternatives are returned to the authoritative phrase evaluator.
|
||||
"""
|
||||
from generator.solo import routes, policy
|
||||
fixed=set(draft.protected)|set(draft.structural)|{e for f in draft.features for e in f.events}
|
||||
fixed.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
|
||||
produced=0
|
||||
for join in (t.start for t in draft.transformations[1:]):
|
||||
inside=tuple(n for n in draft.notes if join-2<=n.start and n.end<=join+2)
|
||||
if len(inside)<3 or any(n.event_id in fixed or n.role=='quote' for n in inside[1:-1]):continue
|
||||
if any(t.start<inside[-1].end and t.end>inside[0].start for t in draft.sustained_tails):continue
|
||||
pattern=tuple((n.start-inside[0].start,n.end-inside[0].start) for n in inside)
|
||||
if not groups.valid(pattern):continue
|
||||
alternatives=[inside]
|
||||
for i,n in enumerate(inside[1:-1],1):
|
||||
if n.end==join and n.end-n.start>=1 and (n.start*2).denominator==1:
|
||||
cut=n.end-F(1,2)
|
||||
alternatives.append((*inside[:i],dataclasses.replace(n,end=cut),
|
||||
dataclasses.replace(n,start=cut,event_id=n.event_id+':extend'),*inside[i+1:]))
|
||||
break
|
||||
for template in alternatives:
|
||||
for route in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'two-sided join'):
|
||||
if route.notes==inside:continue
|
||||
actual=tuple(n for n in draft.notes if n.end<=inside[0].start)+route.notes+tuple(n for n in draft.notes if n.start>=inside[-1].end)
|
||||
if not drafts.contract_valid(draft,actual)[0] or not harmony.evaluate(actual,until=draft.end,complete=True,expressions=draft.expressions).valid:continue
|
||||
transforms=tuple(dataclasses.replace(t,events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
|
||||
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end)) for t in draft.transformations)
|
||||
changed=dataclasses.replace(draft,id=draft.id+':join:'+route.id,notes=actual,transformations=transforms)
|
||||
yield changed,types.DraftEdit('two_sided_join',inside[0].start,inside[-1].end,
|
||||
tuple(n.event_id for n in inside),tuple(n.event_id for n in route.notes),'complete route over both sides; endpoints and contracts preserved'),route
|
||||
produced+=1
|
||||
if produced>=policy.DEFAULT.join_alternatives:return
|
||||
|
||||
|
||||
def fragmentation(notes):
|
||||
isolated = sum(n.end-n.start <= F(1,2) and a.end < n.start and n.end < b.start
|
||||
for a,n,b in zip(notes, notes[1:], notes[2:]))
|
||||
gaps = sum(0 < b.start-a.end <= 1 for a,b in zip(notes, notes[1:]))
|
||||
return isolated + .1*gaps
|
||||
|
||||
|
||||
def shared_arrival(draft,harmony,treatments=()):
|
||||
"""A provisional endpoint may also begin a continuation, without reattack.
|
||||
|
||||
Retain the continuation event's identity. A rhythm/exact-quote contract or
|
||||
a committed destination can forbid sharing; full context is revalidated.
|
||||
"""
|
||||
notes=draft.notes
|
||||
joins={t.start for t in draft.transformations[1:] if t.operation=='continue'}
|
||||
fixed=set(draft.protected)|set(draft.structural)
|
||||
fixed.update(n.event_id for n in notes if n.role=='quote')
|
||||
fixed.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
|
||||
fixed.update(e for f in draft.features if 'rhythm' in f.preserves for e in f.events)
|
||||
edits=[]
|
||||
for join in sorted(joins):
|
||||
for i,(a,b) in enumerate(zip(notes,notes[1:])):
|
||||
if b.start!=join or a.end!=b.start or a.pitch!=b.pitch:continue
|
||||
if a.event_id in fixed or b.event_id in fixed:continue
|
||||
actual=(*notes[:i],dataclasses.replace(b,start=a.start),*notes[i+2:])
|
||||
if not drafts.contract_valid(draft,actual)[0]:continue
|
||||
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True,expressions=draft.expressions).valid:continue
|
||||
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]:continue
|
||||
edits.append(types.DraftEdit('shared_arrival',a.start,b.end,(a.event_id,b.event_id),(b.event_id,),
|
||||
'reopen provisional endpoint as the same sounding continuation; preserve feature and arrival contracts'))
|
||||
notes=actual
|
||||
break
|
||||
if not edits:return draft,()
|
||||
by_id={n.event_id:n for n in notes}
|
||||
transforms=[]
|
||||
for t in draft.transformations:
|
||||
group=tuple(by_id[e] for e in t.events if e in by_id)
|
||||
if group:
|
||||
transforms.append(dataclasses.replace(t,start=group[0].start,end=group[-1].end,
|
||||
events=tuple(n.event_id for n in group),rhythm=tuple((n.start-group[0].start,n.end-group[0].start) for n in group)))
|
||||
return dataclasses.replace(draft,notes=notes,transformations=tuple(transforms)),tuple(edits)
|
||||
|
||||
|
||||
def revise(draft, harmony, treatments=()):
|
||||
draft,shared=shared_arrival(draft,harmony,treatments)
|
||||
notes=draft.notes; edits=list(shared)
|
||||
if len(notes)<3: return draft, tuple(edits)
|
||||
protected=set(draft.protected)|set(draft.structural)|{n.event_id for n in notes if n.role=='quote'}
|
||||
# An identifying rhythm also protects its internal rests.
|
||||
protected.update(n.event_id for c in draft.expressions for n in (*c.events,c.target,*((c.preparation,) if c.preparation else ())))
|
||||
protected.update(e for feature in draft.features if 'rhythm' in feature.preserves for e in feature.events)
|
||||
for _ in range(2):
|
||||
proposals=[]
|
||||
for i,(a,n,b) in enumerate(zip(notes,notes[1:],notes[2:]),1):
|
||||
if n.event_id in protected or n.end-n.start>1: continue
|
||||
if not (a.end<n.start or n.end<b.start): continue
|
||||
if a.event_id not in protected and 0<n.start-a.end<=1:
|
||||
proposals.append((*notes[:i-1],dataclasses.replace(a,end=n.start),*notes[i:]))
|
||||
if 0<b.start-n.end<=1:
|
||||
proposals.append((*notes[:i],dataclasses.replace(n,end=b.start),*notes[i+1:]))
|
||||
for delta in (F(-1,2),F(1,2)):
|
||||
if a.end<=n.start+delta and n.end+delta<=b.start:
|
||||
proposals.append((*notes[:i],dataclasses.replace(n,start=n.start+delta,end=n.end+delta),*notes[i+1:]))
|
||||
if len(proposals)>=8: break
|
||||
valid=[]
|
||||
for actual in proposals[:8]:
|
||||
if not groups.valid(tuple((n.start-draft.start,n.end-draft.start) for n in actual)):continue
|
||||
if fragmentation(actual)>=fragmentation(notes): continue
|
||||
if not drafts.contract_valid(draft,actual)[0]: continue
|
||||
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True,expressions=draft.expressions).valid: continue
|
||||
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]: continue
|
||||
valid.append(actual)
|
||||
if not valid: break
|
||||
actual=min(valid,key=lambda ns:(fragmentation(ns),tuple((n.start,n.end,n.pitch.number()) for n in ns)))
|
||||
changed=[(a,b) for a,b in zip(notes,actual) if (a.start,a.end)!=(b.start,b.end)]
|
||||
edits.append(types.DraftEdit('connected_rhythm',min(min(a.start,b.start) for a,b in changed),
|
||||
max(max(a.end,b.end) for a,b in changed),tuple(a.event_id for a,b in changed),
|
||||
tuple(b.event_id for a,b in changed),'same pitch sequence and attack count; connect an editable gesture'))
|
||||
notes=actual
|
||||
transforms=tuple(dataclasses.replace(t,rhythm=tuple((n.start-t.start,n.end-t.start) for n in notes if n.event_id in t.events))
|
||||
if not t.preserves else t for t in draft.transformations)
|
||||
return dataclasses.replace(draft,notes=notes,transformations=transforms),tuple(edits)
|
||||
@@ -1,149 +0,0 @@
|
||||
"""Joint rhythmic and harmonic actions, constructed before pitch-route search.
|
||||
|
||||
Rules describe musical relationships, never reference-score phrases. Contracts
|
||||
are bound to the selected whole route and validated before a candidate survives.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary, harmony as chords
|
||||
from generator.solo import types, expressions, groups, sequences, policy
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Proposal:
|
||||
notes: tuple
|
||||
contracts: tuple
|
||||
purpose: str
|
||||
|
||||
def bind(self, notes):
|
||||
actual={n.event_id:n for n in notes}
|
||||
return tuple(dataclasses.replace(c,events=tuple(actual[n.event_id] for n in c.events),
|
||||
target=actual[c.target.event_id],preparation=actual[c.preparation.event_id] if c.preparation else None) for c in self.contracts)
|
||||
|
||||
|
||||
def replace(notes, start, end, events):
|
||||
# Provisional rhythm is editable, but a sliced tuplet is not an explanation.
|
||||
before=tuple(dataclasses.replace(n,end=start) if n.end>start else n for n in notes if n.start<start)
|
||||
after=tuple(dataclasses.replace(n,start=end,event_id=n.event_id+':resumed') if n.start<end else n for n in notes if n.end>end)
|
||||
actual=(*before,*events,*after)
|
||||
if any(n.end<=n.start for n in actual):return ()
|
||||
origin=notes[0].start
|
||||
if not groups.valid(tuple((n.start-origin,n.end-origin) for n in actual)):return ()
|
||||
return actual
|
||||
|
||||
|
||||
def proposals(notes,harmony,start,finish,ending=None):
|
||||
"""Plan future arrivals, colors and approaches together with exact rhythm."""
|
||||
rules=vocabulary.data('melodic_vocabulary')['gesture_rules']
|
||||
fixed_end=ending.earliest if ending else finish
|
||||
for rule in rules:
|
||||
if rule['kind']=='harmonic_arrival':
|
||||
for span in harmony.spans:
|
||||
boundary=span.start
|
||||
prior=harmony.at(boundary-F(1,100))
|
||||
if not span.scale or not prior or not prior.scale or prior.chord==span.chord:continue
|
||||
if not start+1<boundary<fixed_end:continue
|
||||
for offset in map(F,rule['arrival_offsets']):
|
||||
arrival=boundary+offset
|
||||
if arrival<start+1:continue
|
||||
for hold in map(F,rule['holds']):
|
||||
end=arrival+hold
|
||||
if end>min(fixed_end,span.end) or end<=boundary:continue
|
||||
breath=min(F(rule['release']),fixed_end-end)
|
||||
near=next((n.pitch.number() for n in notes if n.start<=arrival<n.end),notes[-1].pitch.number())
|
||||
members=harmony.members(boundary if offset<0 else arrival,end)&types.permitted(harmony.choices,boundary if offset<0 else arrival,end)
|
||||
candidates=tuple(types.scale_pitch(span.scale,i) for i in range(-21,29))
|
||||
candidates=tuple(p for p in candidates if p.number()%12 in members and harmony.profile.permits(p.number()))
|
||||
# Third/seventh, root/fifth and register alternatives remain real
|
||||
# competitors; no target name or song position determines a winner.
|
||||
targets=sequences.diverse(candidates,lambda p:(abs(p.number()-near),p.number()),
|
||||
lambda p:next((d for pc,d in span.roles.degrees if pc==p.number()%12),()),3)
|
||||
for pitch in targets:
|
||||
target=types.Note(arrival,end,pitch,notes[0].sources,'arrival',event_id=notes[0].event_id+f':{rule["id"]}:{arrival}:{pitch.number()}')
|
||||
actual=replace(notes,arrival,end+breath,(target,))
|
||||
if len(actual)<2:continue
|
||||
contracts=()
|
||||
if offset<0:
|
||||
contracts=(types.ExpressionContract(target.event_id,'anticipation','early_chord_tone',(target,),target,span.chord,boundary=boundary),)
|
||||
yield Proposal(actual,contracts,rule['id']+':'+str(offset))
|
||||
elif rule['kind']=='sustained_color':
|
||||
for i,n in enumerate(notes):
|
||||
if n.start>=fixed_end or n.end-n.start<1:continue
|
||||
span=harmony.at(n.start)
|
||||
if not span or not span.scale:continue
|
||||
for color in vocabulary.data('harmony')['colors']:
|
||||
if n.end-n.start>F(color['max_beats']) or not expressions.eligible_color(color,span,harmony):continue
|
||||
pitch=expressions.degree_pitch(chords.parse_symbol(span.chord),color['degree'],color['alter'],n.pitch.number())
|
||||
if not harmony.profile.permits(pitch.number()) or pitch==n.pitch:continue
|
||||
changed=dataclasses.replace(n,pitch=pitch,role='color')
|
||||
actual=(*notes[:i],changed,*notes[i+1:]);target=changed
|
||||
if color.get('resolve'):
|
||||
if i+1==len(notes):continue
|
||||
next_note=notes[i+1];future=harmony.at(next_note.start)
|
||||
if not future or not future.scale:continue
|
||||
targets=[types.scale_pitch(future.scale,j) for j in range(-21,29)]
|
||||
targets=[p for p in targets if 0<abs(p.number()-pitch.number())<=2 and p.number()%12 in harmony.members(next_note.start,next_note.end) and harmony.profile.permits(p.number())]
|
||||
if not targets:continue
|
||||
target=dataclasses.replace(next_note,pitch=min(targets,key=lambda p:(abs(p.number()-next_note.pitch.number()),p.number())))
|
||||
actual=(*notes[:i],changed,target,*notes[i+2:])
|
||||
c=types.ExpressionContract(changed.event_id+':'+color['id'],'color',color['id'],(changed,),target,span.chord)
|
||||
yield Proposal(actual,(c,),rule['id'])
|
||||
|
||||
|
||||
def approach(proposal,harmony,start,ending=None):
|
||||
"""An approach may share its target with an anticipated destination."""
|
||||
for i,target in enumerate(proposal.notes):
|
||||
if target.end-target.start<1:continue
|
||||
anticipated=next((c for c in proposal.contracts if c.kind=='anticipation' and c.target.event_id==target.event_id),None)
|
||||
time=anticipated.boundary if anticipated else target.start
|
||||
if target.pitch.number()%12 not in harmony.members(time,target.end):continue
|
||||
span=harmony.at(time)
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
arrival=target.start;cursor=arrival-F(rule['gap'])-sum(map(F,rule['durations']),F())
|
||||
if cursor<start+F(1,2):continue
|
||||
if ending and cursor>=ending.earliest:continue
|
||||
events=[]
|
||||
for j,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])):
|
||||
events.append(types.Note(cursor,cursor+F(duration),expressions.chromatic(target.pitch,step),target.sources,rule['kind'],event_id=target.event_id+':'+rule['id']+':'+str(j)))
|
||||
cursor+=F(duration)
|
||||
actual=replace(proposal.notes,events[0].start,target.start,tuple(events))
|
||||
if not actual:continue
|
||||
if any(n.event_id not in {a.event_id for a in actual} for c in proposal.contracts for n in (*c.events,c.target)):continue
|
||||
prior=next((n for n in reversed(actual) if n.end<=events[0].start),None)
|
||||
c=types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(events),target,span.chord,
|
||||
boundary=anticipated.boundary if anticipated else None,preparation=prior if rule['kind']=='connection' else None)
|
||||
yield Proposal(actual,(*proposal.contracts,c),proposal.purpose+' + '+rule['kind'])
|
||||
|
||||
|
||||
def select(notes,harmony,start,finish,ending=None,limit=4):
|
||||
# Provenance differs across candidate histories; musical work does not.
|
||||
# This cache belongs to one immutable Harmony and includes all timing,
|
||||
# pitches, roles, continuation flags, profile and ending constraints.
|
||||
canonical=tuple(dataclasses.replace(n,sources=(),event_id=f'@{i}@') for i,n in enumerate(notes))
|
||||
key=(canonical,start,finish,ending,limit,harmony.profile)
|
||||
if not hasattr(harmony,'gesture_cache'):harmony.gesture_cache={}
|
||||
if key not in harmony.gesture_cache:
|
||||
harmony.gesture_cache[key]=_select(canonical,harmony,start,finish,ending,limit)
|
||||
if len(harmony.gesture_cache)>4096:harmony.gesture_cache.pop(next(iter(harmony.gesture_cache)))
|
||||
def identity(value):
|
||||
for i,n in enumerate(notes):value=value.replace(f'@{i}@',n.event_id)
|
||||
return value
|
||||
def restore(n):
|
||||
i=int(n.event_id.split('@')[1])
|
||||
return dataclasses.replace(n,event_id=identity(n.event_id),sources=notes[i].sources)
|
||||
return tuple(Proposal(tuple(map(restore,p.notes)),tuple(dataclasses.replace(c,id=identity(c.id),
|
||||
events=tuple(map(restore,c.events)),target=restore(c.target),
|
||||
preparation=restore(c.preparation) if c.preparation else None) for c in p.contracts),p.purpose)
|
||||
for p in harmony.gesture_cache[key])
|
||||
|
||||
|
||||
def _select(notes,harmony,start,finish,ending=None,limit=4):
|
||||
plain=Proposal(notes,(),'direct')
|
||||
base=sequences.diverse(proposals(notes,harmony,start,finish,ending),
|
||||
lambda p:(abs(len(p.notes)-len(notes)),tuple((n.start,n.end,n.pitch.number()) for n in p.notes)),
|
||||
lambda p:(p.purpose,tuple(c.kind for c in p.contracts)),8)
|
||||
values=(*base,*approach(plain,harmony,start,ending),*(p for b in base if b.purpose.startswith('harmonic') for p in approach(b,harmony,start,ending)))
|
||||
# Complete structural choices compete, not one replacement at each attack.
|
||||
return sequences.diverse(values,lambda p:(abs(len(p.notes)-len(notes)),policy.stable_rank('joint-purpose',tuple((n.start,n.end,n.pitch) for n in p.notes))),
|
||||
lambda p:tuple(c.kind for c in p.contracts) or (p.purpose,),limit)
|
||||
@@ -1,451 +0,0 @@
|
||||
"""Source-phrase variations: edit actual music, then realize complete routes.
|
||||
|
||||
The external lead references are audition controls, never production seeds.
|
||||
All notes use sounding coordinates and all time uses quarter-note Fractions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
import generator.vocabulary
|
||||
|
||||
import generator.model
|
||||
import generator.fretboard
|
||||
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, features as references, profiles
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
|
||||
def signature(notes):
|
||||
return tuple((n.start, n.end, n.pitch.number()) for n in notes)
|
||||
|
||||
|
||||
def shift(notes, octave):
|
||||
return tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave+octave),
|
||||
position=None, role='source', event_id=n.event_id or n.sources[0]) for n in notes)
|
||||
|
||||
|
||||
def available(harmony, start, end, *, members=False):
|
||||
if not hasattr(harmony,'available_pitches'):harmony.available_pitches={}
|
||||
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 ()
|
||||
key=(span.scale,allowed,harmony.profile)
|
||||
if key not in harmony.available_pitches:
|
||||
harmony.available_pitches[key]=tuple(p for i in range(-35,28)
|
||||
if harmony.profile.register[0]<=(p:=types.scale_pitch(span.scale,i)).number()<=harmony.profile.register[1]
|
||||
and p.number()%12 in allowed and placement.positions(p.number(),harmony.profile))
|
||||
return harmony.available_pitches[key]
|
||||
|
||||
|
||||
def adapt(notes, harmony):
|
||||
"""Document required harmonic rewrites; never repair a sequence one step at a time."""
|
||||
result, edits = [], []
|
||||
for index, note in enumerate(notes):
|
||||
terminal = index == len(notes)-1
|
||||
choices = available(harmony,note.start,note.end,members=terminal or note.end-note.start >= 2)
|
||||
pieces = [(note.start,note.end,choices)]
|
||||
if not choices:
|
||||
pieces = [(a,b,available(harmony,a,b,members=terminal or b-a >= 2)) for _,a,b in harmony.segments(note)]
|
||||
for j,(start,end,domain) in enumerate(pieces):
|
||||
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)
|
||||
|
||||
|
||||
def rhythm_catalog(library, meter):
|
||||
# Keep complete snippet objects. Slices below retain real rests and durations.
|
||||
return drafts.catalog(library,meter)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def connection_windows(catalog,length):
|
||||
spans={( (F(),length), ):'held'}
|
||||
for identifier,pattern,total in catalog:
|
||||
if total < length:
|
||||
continue
|
||||
# Retrieve every exact-duration window, including offbeat and cross-bar windows.
|
||||
for offset in sorted({F(),*(a for a,_ in pattern)}):
|
||||
if offset+length>total:
|
||||
continue
|
||||
if 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 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)
|
||||
return ranked
|
||||
|
||||
def connection_patterns(start,end,entry,destination,rhythms,meter):
|
||||
ranked=connection_windows(tuple(rhythm_catalog(rhythms,meter)),end-start)
|
||||
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, tails=None):
|
||||
"""Replace complete internal spans while retaining identity and sounding tails."""
|
||||
legacy=tails is None
|
||||
tails=tails or ()
|
||||
fixed={e for f in features for e in f.events} if legacy else {n.event_id for n in notes if n.role=='quote'}
|
||||
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,i+1,start,target))
|
||||
if not legacy:
|
||||
for j in range(i+2,min(i+5,len(notes))):
|
||||
target=notes[j]
|
||||
if not 1<=target.start-note.start<=3:continue
|
||||
if any(n.event_id in fixed for n in notes[i:j]):continue
|
||||
if any(t.start<target.start and t.end>note.start for t in tails):continue
|
||||
windows.append((i,j,note.start,target))
|
||||
if legacy and 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,len(notes)-1,start,target))
|
||||
# Consequential edits only; do not exhaustively branch at each passing note.
|
||||
windows=sequences.diverse(windows,lambda w:(policy.stable_rank(source.id,w),),lambda w:w[0],8)
|
||||
result=[]
|
||||
for i,j,start,target in windows:
|
||||
entry=notes[i]
|
||||
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,target.start,seed,target,rhythms,meter):
|
||||
for route in routes.candidates(pattern,{target.event_id},harmony,library,(41,76),'develop '+source.id):
|
||||
if sustain_entry or start==entry.start:
|
||||
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[j+1:])
|
||||
else:
|
||||
actual=(*notes[:i],prefix,*route.notes,*notes[j+1:])
|
||||
if any(references.feature_error(f,actual) for f in features):continue
|
||||
if any(not any(n.pitch.number()==t.pitch and n.start<=t.start and n.end>=t.end for n in actual) for t in tails):continue
|
||||
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,tuple(n.event_id for n in notes[i:j+1]),tuple(n.event_id for n in route.notes),
|
||||
source.id+'; '+rhythm_id+'; lead into '+target.pitch.label())
|
||||
result.append((actual,edit,route))
|
||||
return sequences.diverse(result,lambda v:(max(0,4-len(v[0])*4/float(source.end-source.start),len(v[0])*4/float(source.end-source.start)-6),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=[]
|
||||
for i,n in enumerate(notes):
|
||||
if n.pitch.number()%12 not in harmony.members(n.start,n.end):
|
||||
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
|
||||
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,signature(notes))[:12]
|
||||
motif=types.Motif(source.id,tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start) for n in refined),
|
||||
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)
|
||||
home=realization.make_identity(identifier,source.id,refined,source.start,source.end,'source_variation',harmony)
|
||||
transform=types.Transformation(identifier,source.id,source.family,'source_variation',source.start,source.end,
|
||||
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)
|
||||
draft=types.MelodicDraft(identifier,source.start,source.end,notes,(transform,),
|
||||
tuple(a.id.removesuffix(':anchor') for a in anchors),(),tuple(anchors),home,'source_variation',features=features,
|
||||
reference=source.id,reference_motif=motif,source_phrase=source,octave_transfer=octave,
|
||||
source_links=tuple((n.event_id,n.sources) for n in notes))
|
||||
spaces=[];cursor=source.start
|
||||
for n in notes:
|
||||
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(source, harmony, sequences_library, rhythms, meter, part, tempo, seed, *, stage='elaborated'):
|
||||
result=[]
|
||||
for octave in range(-3,4):
|
||||
original=shift(source.notes,octave)
|
||||
if any(not generator.fretboard.single_positions_for_pitch(F(n.pitch.number())) for n in original):
|
||||
continue
|
||||
adapted,required=adapt(original,harmony)
|
||||
if original and not adapted and any(harmony.at(n.start) and harmony.at(n.start).scale for n in original):
|
||||
continue
|
||||
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:
|
||||
result.append(phrase)
|
||||
# Preserve register AND edit-location diversity, rather than eight near-copies.
|
||||
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 clipped_sources(analysis,end):
|
||||
result=[]
|
||||
for source in analysis.source_phrases:
|
||||
if source.start>=end:break
|
||||
finish=min(source.end,end)
|
||||
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)
|
||||
result.append(dataclasses.replace(source,end=finish,notes=notes,rests=tuple((a,min(b,finish)) for a,b in source.rests if a<finish)))
|
||||
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 thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage):
|
||||
from generator.solo import realization,themes,phrasing
|
||||
context.resources[id(library)]=library
|
||||
context.tempo=analysis.tempo
|
||||
context.resources[id(rhythms)]=rhythms
|
||||
harmony=harmonic.Harmony(choices, profiles.for_part(part,analysis))
|
||||
seed=policy.stable_rank('source_variations',(part,analysis.melody,choices,generator.vocabulary.role(part+'_solo'),policy.DEFAULT.composition_version))
|
||||
states=[(0.,())]
|
||||
failures=[]
|
||||
for _ in range(2*len(measures)+1):
|
||||
if all(history and history[-1].plan.end==end for _,history in states):break
|
||||
following=[]
|
||||
for cost,history in states:
|
||||
start=history[-1].plan.end if history else F()
|
||||
if start==end:
|
||||
following.append((cost,history));continue
|
||||
incoming=history[-1].state if history else types.ContinuationState('')
|
||||
sources=phrasing.window_options(analysis,measures,start,end,harmony,incoming)
|
||||
elapsed=F();meter=measures[0]['time_signature']
|
||||
for measure in measures:
|
||||
elapsed+=generator.model.fraction(measure['duration'])
|
||||
if elapsed>start:
|
||||
meter=measure['time_signature'];break
|
||||
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
||||
harmony.bar=bar
|
||||
for source in sources:
|
||||
key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT,harmony.profile)
|
||||
if key in context.results:
|
||||
pool=context.results[key];context.diagnostics['cache_hits']+=1
|
||||
else:
|
||||
pool=()
|
||||
if not harmony.at(start) or not harmony.at(start).scale:
|
||||
pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed)
|
||||
else:
|
||||
for batch in range(policy.DEFAULT.replan_attempts):
|
||||
pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed,
|
||||
stage=stage,batch=batch,final=source.end==end,solo_end=end)
|
||||
if pool:break
|
||||
context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1
|
||||
context.results[key]=pool
|
||||
context.diagnostics.setdefault('source_candidate_pools',{}).setdefault(source.id,[]).extend(
|
||||
{'id':p.draft.id,'score':p.score,'attacks':len(p.draft.notes),'theme':p.state.home.id if p.state.home else None,
|
||||
'ending':str(p.anchors[-1].duration) if p.anchors else None,'returns':len(p.state.returns),'evaluation':dataclasses.asdict(p.evaluation) if p.evaluation else None} for p in pool)
|
||||
for candidate in pool:
|
||||
span=float((source.end-source.start)/end)
|
||||
total=cost+span*candidate.score
|
||||
following.append((total,(*history,candidate)))
|
||||
if not pool:failures.append(f'{source.id}: planned sustained destination, feature or incoming continuation')
|
||||
if not following:
|
||||
raise realization.SearchExhausted(f'{part}: {"; ".join(failures[-4:])} failed within the bounded search; not proof of incompatibility')
|
||||
def rank(value):
|
||||
cost,history=value;state=history[-1].state;elapsed=history[-1].plan.end
|
||||
musical=cost/max(.001,float(elapsed/end))
|
||||
return musical,policy.stable_rank(seed,tuple(p.draft.id for p in history))
|
||||
states=sequences.diverse(following,rank,
|
||||
lambda v:(v[1][-1].state.home.id if v[1][-1].state.home else '',v[1][-1].plan.end,len(v[1][-1].state.returns)),policy.DEFAULT.chorus_frontier)
|
||||
finished=[(cost,history) for cost,history in states if history[-1].plan.end==end]
|
||||
if not finished:raise realization.SearchExhausted(f'{part}: phrase-boundary search budget exhausted; not proof of incompatibility')
|
||||
return tuple((rank((cost,phrases))[0],types.Solo(part,choices,phrases,analysis.tempo,end,
|
||||
{**context.diagnostics,'selection_total':rank((cost,phrases))[0], 'selection_scope':'duration-weighted complete phrase evaluations; partial frontiers use elapsed-duration rates', 'construction':'thematic_recomposition','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),harmony.profile)) for cost,phrases in finished)
|
||||
|
||||
|
||||
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
|
||||
from generator.solo import realization
|
||||
if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode)
|
||||
if mode=='thematic':
|
||||
return thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage)
|
||||
context.resources[id(library)]=library
|
||||
context.resources[id(rhythms)]=rhythms
|
||||
harmony=harmonic.Harmony(choices, profiles.for_part(part,analysis))
|
||||
sources=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,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:
|
||||
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:
|
||||
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:(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':'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),harmony.profile)) 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
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Realized gesture identities and their explicit development relationships."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.scoring
|
||||
import generator.solo.types
|
||||
|
||||
def reference(plan, gesture, realized):
|
||||
if not realized or gesture is None or gesture.relationship == 'statement':
|
||||
return plan.motif
|
||||
# Repetition returns to the introduced identity; extension/answer develops
|
||||
# the most recently heard related gesture, never an unrelated catalog winner.
|
||||
return realized[0] if gesture.relationship == 'repeat_with_harmonic_adaptation' else realized[-1]
|
||||
|
||||
def local_notes(notes, start, end):
|
||||
return tuple(dataclasses.replace(n, start=n.start - start, end=min(n.end, end) - start, position=None, continuation=False) for n in notes if start <= n.start < end)
|
||||
|
||||
def complete(gesture, reference, notes, harmony):
|
||||
actual = local_notes(notes, gesture.start, gesture.end)
|
||||
sounded = any(n.start < gesture.end and n.end > gesture.start for n in notes)
|
||||
active = any(s.scale and s.start < gesture.end and s.end > gesture.start for s in harmony.spans)
|
||||
if not active:
|
||||
return True, 0.0, None
|
||||
if gesture.relationship == 'close':
|
||||
return sounded, 0.0, None
|
||||
required = min(len(reference.notes), 2)
|
||||
if len(actual) < required:
|
||||
return False, 1.0, None
|
||||
fidelity = generator.solo.scoring.motif_fidelity(actual, reference, gesture.treatment)
|
||||
if gesture.treatment == 'rhythmic_reference':
|
||||
expected = tuple((n.start, min(n.end, gesture.end - gesture.start)) for n in reference.notes if n.start < gesture.end - gesture.start)
|
||||
if tuple((n.start, n.end) for n in actual) != expected:
|
||||
return False, 1.0, None
|
||||
elif gesture.relationship != 'statement' and fidelity >= 0.75:
|
||||
return False, fidelity, None
|
||||
degrees = []
|
||||
for n in actual:
|
||||
span = harmony.at(gesture.start + n.start)
|
||||
degrees.append(dict(span.roles.degrees).get(n.pitch.number() % 12, ()) if span else ())
|
||||
result = generator.solo.types.RealizedMotif(gesture.id, reference.id, actual, gesture.end - gesture.start, gesture.relationship, gesture.treatment, tuple(degrees), gesture.start)
|
||||
return True, fidelity, result
|
||||
|
||||
def interval_steps(reference, harmony, source_start=None):
|
||||
result = []
|
||||
source_start = getattr(reference, 'source_start', source_start or fractions.Fraction())
|
||||
for first, second in zip(reference.notes, reference.notes[1:]):
|
||||
span = harmony.at((source_start or fractions.Fraction()) + first.start)
|
||||
try:
|
||||
step = generator.solo.types.scale_index(span.scale, second.pitch.number()) - generator.solo.types.scale_index(span.scale, first.pitch.number()) if span and span.scale else None
|
||||
except ValueError:
|
||||
step = None
|
||||
result.append((step, second.pitch.number() - first.pitch.number()))
|
||||
return tuple(result)
|
||||
@@ -1,253 +0,0 @@
|
||||
"""Sustained destinations and bounded memory for the existing theme composer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
|
||||
import generator.model
|
||||
import generator.harmony
|
||||
import generator.vocabulary
|
||||
from generator.solo import types, policy, sequences, profiles
|
||||
|
||||
|
||||
def heard(notes, operation, anchor=None, bar=F(4)):
|
||||
notes = types.sounding_notes(notes)
|
||||
first = notes[0].start
|
||||
events = tuple((n.start-first, n.end-n.start, n.pitch.number()) for n in notes)
|
||||
ending = (anchor.pitch.number()%12, anchor.earliest%bar, anchor.duration) if anchor and anchor.role == 'terminal' else ()
|
||||
return types.HeardFigure(first, notes[-1].end, events, operation, ending)
|
||||
|
||||
|
||||
def remember(history, figure, bar):
|
||||
return tuple(h for h in (*history, figure) if h.end > figure.end-policy.DEFAULT.history_bars*bar)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def fingerprints(events, relative=False):
|
||||
result = set()
|
||||
for size in (3, 4, 6, 8):
|
||||
for i in range(len(events)-size+1):
|
||||
group = events[i:i+size]
|
||||
start, _, pitch = group[0]
|
||||
result.add(tuple((a-start, d, p-pitch if relative else p) for a,d,p in group))
|
||||
return frozenset(result)
|
||||
|
||||
|
||||
def repetition(figure, history, bar):
|
||||
"""Bounded overuse, not a reward for arbitrary difference or extra attacks."""
|
||||
exact, relative = fingerprints(figure.events), fingerprints(figure.events, True)
|
||||
overuse = cadence = 0.
|
||||
for old in history:
|
||||
age = max(0., float((figure.start-old.end)/bar))
|
||||
recent = max(0., 1-age/policy.DEFAULT.history_bars)
|
||||
a,b = fingerprints(old.events), fingerprints(old.events, True)
|
||||
exact_match = len(exact & a)/max(1,min(len(exact),len(a)))
|
||||
shifted_match = len(relative & b)/max(1,min(len(relative),len(b)))
|
||||
overuse += recent*(.65*exact_match+.35*shifted_match)
|
||||
if figure.ending and figure.ending == old.ending:
|
||||
cadence += recent
|
||||
pitches = tuple(p for _,_,p in figure.events)
|
||||
oscillation = sum(a==c and b==d for a,b,c,d in zip(pitches,pitches[1:],pitches[2:],pitches[3:]))/max(1,len(pitches)-3)
|
||||
discount = .45 if figure.operation == 'return' else 1.
|
||||
return min(1., discount*overuse/2) + .3*oscillation + .3*min(1.,cadence/2)
|
||||
|
||||
|
||||
def register_goal(incoming, part, fallback, profile=None):
|
||||
if profile and profile.reference is not None:
|
||||
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
|
||||
center = round(sum(recent)/len(recent)) if recent else profile.reference
|
||||
# Follow a whole-gesture excursion with a possible return, not an
|
||||
# instrument-specific octave or a repeating register cycle.
|
||||
if center>profile.reference+7:goal=profile.reference
|
||||
elif center<profile.reference-7:goal=profile.reference
|
||||
else:
|
||||
motion=incoming.direction*4 if incoming.home else 0
|
||||
goal=profile.reference+motion
|
||||
return max(profile.register[0]+3,min(profile.register[1]-3,round((center+goal)/2)))
|
||||
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
|
||||
if not recent:
|
||||
return fallback
|
||||
center = sum(recent)/len(recent)
|
||||
if part == 'electric' and (center >= 67 or incoming.register_peak >= 72 and center >= 64):
|
||||
return max(48, round(center)-7)
|
||||
if part == 'acoustic' and max(recent)-min(recent) < 8:
|
||||
home = sum(n.pitch.number() for n in incoming.home.notes)/len(incoming.home.notes) if incoming.home else center
|
||||
return min(72, round(center)+5) if center <= home+7 else max(45, round(center)-5)
|
||||
return round(center)
|
||||
|
||||
|
||||
def endpoint_cost(finish, start, end, count, harmony, bar):
|
||||
length = float((finish-start)/bar)
|
||||
remaining = float((end-finish)/bar)
|
||||
pacing = 0.
|
||||
span = harmony.at(finish-F(1,100))
|
||||
# A dominant may close an open question, but a nearby resolution is preferred.
|
||||
degrees=generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)) if span and span.scale else {}
|
||||
dominant=degrees.get(3)==0 and degrees.get(7)==-1
|
||||
short_remainder=.2*max(0,4-remaining) if remaining else 0.
|
||||
return pacing+(.08 if ending_effect(finish-F(1,100),harmony,False)=='local_settle' else 0)+.035*abs(length-5)+.16*dominant+.1*(length==8)+short_remainder
|
||||
|
||||
|
||||
def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2):
|
||||
from generator.solo.themes import source_notes
|
||||
bounds=[F()]
|
||||
for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration']))
|
||||
index=next((i for i,b in enumerate(bounds[:-1]) if b<=start<bounds[i+1]),len(measures)-1)
|
||||
bar=bounds[index+1]-bounds[index]
|
||||
current=harmony.at(start)
|
||||
if not current or not current.scale:
|
||||
ends=(min(end,current.end if current else bounds[index+1]),)
|
||||
else:
|
||||
barrier=min((s.start for s in harmony.spans if not s.scale and s.start>start),default=end)
|
||||
authored=[n.end-analysis.body_offset for n in analysis.melody if n.end-n.start>=2]
|
||||
ends=tuple(sorted({min(end,barrier,bounds[min(index+n,len(measures))]) for n in (3,4,5,6,8)} | {t for t in authored if start+3*bar<=t<=min(end,barrier,start+8*bar)}))
|
||||
if barrier>=end:
|
||||
# The returning pickup may shorten the last measure. Do not strand a
|
||||
# one-beat "new phrase" after an otherwise completed sustained close.
|
||||
ends=tuple(sorted({end if 0<end-finish<bar else finish for finish in ends}))
|
||||
melody=source_notes(analysis)
|
||||
values=[]
|
||||
for finish in ends:
|
||||
if finish<=start:continue
|
||||
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)
|
||||
values.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),''))
|
||||
return tuple(sorted(values,key=lambda s:(endpoint_cost(s.end,start,end,incoming.closures,harmony,bar),s.end))[:limit])
|
||||
|
||||
|
||||
def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
|
||||
"""Choose pitch AND sounding interval before generating the approach."""
|
||||
from generator.solo.leads import available
|
||||
base=incoming.last.pitch.number() if incoming.last else source.notes[0].pitch.number() if source.notes else 55
|
||||
goal=register_goal(incoming,part,base,harmony.profile)
|
||||
values=[]
|
||||
duration=source.end-source.start
|
||||
holds=tuple(F(v) for v in generator.vocabulary.data('grammar')['ending_holds']) if duration>=4 else (min(F(2),duration/2),)
|
||||
for hold in holds:
|
||||
for release in map(F,generator.vocabulary.data('grammar')['ending_releases']):
|
||||
arrival=source.end-release-hold
|
||||
if arrival-source.start<F(3,2):continue
|
||||
for pitch in available(harmony,arrival,arrival+hold,members=True):
|
||||
if abs(pitch.number()-goal)>12:continue
|
||||
anchor=types.Anchor(f'{source.id}:close:{arrival}:{pitch.number()}',arrival,arrival,pitch,
|
||||
harmony.members(arrival,arrival+hold),harmony.profile.register,hold,'terminal' if final or ending_effect(arrival,harmony)=='cadential_arrival' else 'intermediate',harmony.at(arrival).chord,
|
||||
hold_until=arrival+hold,effect=ending_effect(arrival,harmony,final))
|
||||
cadence=sum(h.ending==(pitch.number()%12,arrival%bar,hold) for h in incoming.history)
|
||||
rank=harmony.profile.cost(pitch.number())+destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)
|
||||
values.append((rank,anchor))
|
||||
selected=destination_options(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
|
||||
lambda x:x[1].pitch,lambda x:x[1].earliest,harmony,limit)
|
||||
return tuple(a for _,a in selected)
|
||||
|
||||
|
||||
def appearance_cost(operation, incoming, start, end):
|
||||
gap=start-(incoming.returns[-1] if incoming.returns else F())
|
||||
if operation=='return':
|
||||
return .5*max(0,1-float(gap)/16)
|
||||
return .1*min(1,max(0,float(gap)/32-1))
|
||||
|
||||
|
||||
def activity(source, ending, arrivals=()):
|
||||
if arrivals:
|
||||
holds=[(n.start,n.end) for n in arrivals]
|
||||
if ending:holds.append((ending.earliest,ending.hold_until))
|
||||
points=sorted({source.start,source.end,*(t for a,b in holds for t in (a,b)),*([ending.hold_until] if ending else [])})
|
||||
result=[]
|
||||
for a,b in zip(points,points[1:]):
|
||||
held=any(x<=a and b<=y for x,y in holds)
|
||||
space=bool(ending and a>=ending.hold_until)
|
||||
result.append(types.ActivitySpan(a,b,'space' if space else 'sustained_arrival' if held else 'active',
|
||||
F() if held or space else F(5,4),F(1) if held else F()))
|
||||
return tuple(result)
|
||||
if ending is None:return (types.ActivitySpan(source.start,source.end,'active',F(5,4),F()),)
|
||||
spans=[]
|
||||
if ending.earliest>source.start:
|
||||
spans.append(types.ActivitySpan(source.start,ending.earliest,'active',F(5,4),F()))
|
||||
spans.append(types.ActivitySpan(ending.earliest,ending.hold_until,'sustained_arrival',F(),F(1)))
|
||||
if ending.hold_until<source.end:
|
||||
spans.append(types.ActivitySpan(ending.hold_until,source.end,'space',F(),F()))
|
||||
return tuple(spans)
|
||||
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def defining_members(symbol):
|
||||
"""Written chord identity, without inventing omitted thirds or sevenths."""
|
||||
chord=generator.harmony.parse_symbol(symbol)
|
||||
degrees=generator.harmony.degrees(chord)
|
||||
root=generator.model.NATURAL[chord['root']['letter']]+generator.model.alteration(chord['root']['alter'])
|
||||
selected={d for d in (3,6,7) if d in degrees}
|
||||
if chord['kind'] in ('suspended-second','suspended-fourth'):
|
||||
selected.update(d for d in (2,4) if d in degrees)
|
||||
if degrees.get(5,0):selected.add(5)
|
||||
return frozenset(int(root+generator.harmony.degree_semitones(d,degrees[d]))%12 for d in selected)
|
||||
|
||||
|
||||
def destination_cost(pitch, start, end, harmony, incoming=None, goal=None, terminal=False):
|
||||
"""Shared bounded function/trajectory cost at retrieval and final selection."""
|
||||
span=harmony.at(start)
|
||||
if not span or not span.scale:return float('inf')
|
||||
number=pitch.number(); pc=number%12
|
||||
if pc not in harmony.members(start,end):return float('inf')
|
||||
tonic=generator.model.parse_pitch_name(span.scale.root)
|
||||
tonic_pc=int(generator.model.NATURAL[tonic['letter']]+generator.model.alteration(tonic['alter']))%12
|
||||
function=0. if pc in defining_members(span.chord) or terminal and pc==tonic_pc else .25
|
||||
register=min(.15,abs(number-goal)/80) if goal is not None else 0.
|
||||
entry=min(.15,max(0,abs(number-incoming.last.pitch.number())-7)/80) if incoming and incoming.last else 0.
|
||||
previous=next((h.ending[0] for h in reversed(incoming.history) if h.ending),None) if incoming else None
|
||||
recurrence=.04 if previous==pc else 0.
|
||||
following=next((s for s in harmony.spans if s.start>=end and s.scale and s.roles.basic),None)
|
||||
lookahead=0.
|
||||
if following:
|
||||
future=[p for p in range(max(41,number-12),min(76,number+12)+1) if p%12 in following.roles.basic and p%12 in following.scale.pitches]
|
||||
if future:lookahead=.04*min(1,min(abs(p-number) for p in future)/5)
|
||||
# Contour comes from the heard theme, not a universal upward/nearest rule.
|
||||
direction=0.
|
||||
if incoming and incoming.home and len(incoming.home.notes)>1 and incoming.last:
|
||||
motion=incoming.home.notes[-1].pitch.number()-incoming.home.notes[0].pitch.number()
|
||||
direction=.025 if (number-incoming.last.pitch.number())*motion<0 else 0.
|
||||
pending=.08 if incoming and incoming.next_destination and pc not in incoming.next_destination else 0.
|
||||
return function+register+entry+recurrence+lookahead+direction+pending
|
||||
|
||||
|
||||
def destination_family(pitch, time, harmony):
|
||||
return next((degrees for pc,degrees in harmony.at(time).roles.degrees if pc==pitch.number()%12),())
|
||||
|
||||
|
||||
def destination_options(values, rank, pitch, time, harmony, limit):
|
||||
"""Keep chord-degree representatives before register/timing variants."""
|
||||
ordered=sorted(values,key=rank)
|
||||
selected=[];seen=set()
|
||||
for value in ordered:
|
||||
family=destination_family(pitch(value),time(value),harmony)
|
||||
if family not in seen:
|
||||
selected.append(value);seen.add(family)
|
||||
if len(selected)==limit:return tuple(selected)
|
||||
for value in ordered:
|
||||
if value not in selected:selected.append(value)
|
||||
if len(selected)==limit:break
|
||||
return tuple(selected)
|
||||
|
||||
|
||||
def ending_effect(time, harmony, final=False):
|
||||
if final: return 'handoff'
|
||||
current=harmony.at(time)
|
||||
previous=next((s for s in reversed(harmony.spans) if current and s.end<=current.start and s.chord!=current.chord and s.scale),None)
|
||||
if not current or not current.scale or not previous: return 'local_settle'
|
||||
old=generator.harmony.parse_symbol(previous.chord)
|
||||
new=generator.harmony.parse_symbol(current.chord)
|
||||
degrees=generator.harmony.degrees(old)
|
||||
def root(chord):
|
||||
p=chord['root'];return int(generator.model.NATURAL[p['letter']]+generator.model.alteration(p['alter']))%12
|
||||
if degrees.get(3)==0 and degrees.get(7)==-1 and (root(old)-root(new))%12==7:
|
||||
return 'cadential_arrival'
|
||||
return 'local_settle'
|
||||
|
||||
|
||||
def next_destination(anchor,harmony):
|
||||
if anchor.effect!='local_settle': return ()
|
||||
following=[s for s in harmony.spans if s.start>=anchor.hold_until and s.scale]
|
||||
resolution=next((s for s in following[:2] if ending_effect(s.start,harmony)=='cadential_arrival'),None)
|
||||
selected=resolution or next(iter(following),None)
|
||||
return tuple(sorted(selected.roles.basic & selected.scale.pitches)) if selected else ()
|
||||
@@ -1,220 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.scales.selection
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.analysis
|
||||
import generator.solo.policy
|
||||
import generator.solo.sequences
|
||||
import generator.solo.types
|
||||
|
||||
def target_pitches(chord: dict, final: bool) -> frozenset[int]:
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
explicit = {d['value'] for d in chord.get('degrees', []) if d['type'] != 'subtract'}
|
||||
preferred = {1, 3} if final else {3, 7}
|
||||
preferred |= explicit | {d for d, a in degrees.items() if d == 5 and a}
|
||||
if 3 not in degrees:
|
||||
preferred |= {2, 4}
|
||||
root = generator.scales.selection.root_number(chord)
|
||||
return frozenset(int(root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items() if d in preferred) or frozenset(generator.harmony.pitch_classes(chord))
|
||||
|
||||
def destination(choices, start, end, final=False, release=fractions.Fraction(1, 2), closure='closed'):
|
||||
last = next((c for c in reversed(choices) if c.span.start < end and c.span.end > start), None)
|
||||
if not last or not last.span.chord:
|
||||
return None
|
||||
latest = end - release - fractions.Fraction(1, 4)
|
||||
if last.span.start > latest:
|
||||
return None
|
||||
pitches = target_pitches(last.span.chord, final)
|
||||
if closure == 'open' and last.scale:
|
||||
root = generator.scales.selection.root_number(last.span.chord)
|
||||
degrees = generator.harmony.degrees(last.span.chord)
|
||||
additions = {int(root + generator.harmony.degree_semitones(d, degrees.get(d, 0))) % 12 for d in (5, 9, 11, 13) if d not in {v['value'] for v in last.span.chord.get('degrees', []) if v['type'] == 'subtract'}}
|
||||
pitches = frozenset(additions & last.scale.pitches) or pitches
|
||||
return generator.solo.types.TargetEvent(max(start, last.span.start, end - 2 - release), latest, pitches, generator.harmony.symbol(last.span.chord), 'open continuation' if closure == 'open' else 'phrase resolution' if final else 'defining chord-tone arrival')
|
||||
|
||||
def new_motif(sequences: generator.solo.sequences.Library, length: fractions.Fraction, *, choices=(), register=(55, 74), seed='', variant=0) -> generator.solo.types.Motif:
|
||||
if choices and any(c.scale for c in choices):
|
||||
scale = next(c.scale for c in choices if c.scale)
|
||||
arrival = next(c for c in reversed(choices) if c.scale)
|
||||
count = min(6, max(3, int(length * fractions.Fraction(3, 2))))
|
||||
shapes = (('rising', 4), ('falling', -4), ('arch', 1), ('valley', -1), ('leap_return', 2), ('neighbor', 0))
|
||||
shape, displacement = shapes[(int(generator.solo.policy.stable_rank(seed, 'invention')[:8], 16) + variant) % len(shapes)]
|
||||
paths = sequences.retrieve(count, displacement, (displacement > 0) - (displacement < 0))
|
||||
candidates = []
|
||||
target = target_pitches(arrival.span.chord, False)
|
||||
for path in paths:
|
||||
for start in range(-14, 15):
|
||||
pitches = tuple(generator.solo.types.scale_pitch(scale, start + offset) for offset in path.offsets())
|
||||
if not all(register[0] <= pitch.number() <= register[1] and generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch.number())) for pitch in pitches):
|
||||
continue
|
||||
score = (generator.solo.sequences.family(path.steps) != shape, pitches[-1].number() % 12 not in target, abs(path.offsets()[-1] - displacement), abs(pitches[0].number() - (register[0] + 5)), generator.solo.policy.stable_rank(seed, (variant, path.steps, start)))
|
||||
candidates.append((score, pitches, path))
|
||||
if candidates:
|
||||
_, pitches, path = min(candidates, key=lambda c: c[0])
|
||||
unit = min(fractions.Fraction(1, 2), length / count)
|
||||
notes = tuple(generator.solo.types.Note(i * unit, (i + 1) * unit, pitch) for i, pitch in enumerate(pitches))
|
||||
identifier = generator.solo.policy.stable_rank(seed, (variant, path.steps))[:12]
|
||||
return generator.solo.types.Motif('invented_' + identifier, notes, length, (), 'sequences ' + ' + '.join(path.sources))
|
||||
paths = sequences.connecting(3) or sequences.connecting(2) or sequences.connecting(4)
|
||||
if not paths:
|
||||
raise ValueError('solo composition requires a two-, three-, or four-note sequence')
|
||||
path = min(paths, key=lambda s: (sum(a * b >= 0 for a, b in zip(s.steps, s.steps[1:])), s.rank()))
|
||||
count = len(path.steps) + 1
|
||||
unit = min(fractions.Fraction(1, 2), length / (count + 1))
|
||||
notes = []
|
||||
for i, offset in enumerate(path.offsets()):
|
||||
octave, degree = divmod(28 + offset, 7)
|
||||
pitch = generator.solo.types.Pitch(generator.model.LETTERS[degree], fractions.Fraction(), octave)
|
||||
notes.append(generator.solo.types.Note(unit * i, unit * (i + 1), pitch))
|
||||
return generator.solo.types.Motif('new_' + path.id.replace('/', '_').removesuffix('.xml'), tuple(notes), length, (), 'sequence ' + path.id)
|
||||
|
||||
def choose_motifs(analysis, sequences, length):
|
||||
eligible = [m for m in analysis.motifs if m.duration <= 2 * length]
|
||||
head = next((m for m in eligible if len(m.notes) >= 2), eligible[0] if eligible else new_motif(sequences, length))
|
||||
others = [m for m in eligible if m.id != head.id]
|
||||
contrast = min(others, key=lambda m: (m.contour() == head.contour(), abs(len(m.notes) - 3), m.duration, m.id)) if others else head
|
||||
return head, contrast, new_motif(sequences, length)
|
||||
|
||||
def activity_passages(start, end, release, activity, shape=0):
|
||||
music_end = end - release
|
||||
boundaries = sorted({start, music_end} | {start + fractions.Fraction(round(float((end - start) * i / 4) * 2), 2) for i in (1, 2, 3) if start < start + fractions.Fraction(round(float((end - start) * i / 4) * 2), 2) < music_end})
|
||||
weights = ((1, 1.6, 1.1), (0.8, 1.7, 1.2), (1.5, 1.4, 0.8))[shape % 3]
|
||||
spans = list(zip(boundaries, boundaries[1:]))
|
||||
desired = activity * (end - start)
|
||||
denominator = sum(float(b - a) * weights[min(i, 2)] for i, (a, b) in enumerate(spans))
|
||||
result = []
|
||||
for i, (a, b) in enumerate(spans):
|
||||
rate = fractions.Fraction(str(min(2, float(desired) * weights[min(i, 2)] / max(denominator, 0.001)))).limit_denominator(1000)
|
||||
occupancy = generator.solo.policy.DEFAULT.active_occupancy
|
||||
result.append(generator.solo.types.ActivitySpan(a, b, 'active', rate, fractions.Fraction(str(occupancy))))
|
||||
if release:
|
||||
result.append(generator.solo.types.ActivitySpan(music_end, end, 'space', fractions.Fraction(), fractions.Fraction()))
|
||||
return tuple(result)
|
||||
|
||||
def decision(seed, name):
|
||||
return int(generator.solo.policy.stable_rank(seed, name)[:8], 16)
|
||||
|
||||
def same_harmony(left, right):
|
||||
return bool(left and right and left.span.chord and right.span.chord and generator.harmony.symbol(left.span.chord) == generator.harmony.symbol(right.span.chord))
|
||||
|
||||
def resolutions(choices):
|
||||
distinct = []
|
||||
for choice in choices:
|
||||
if not distinct or not same_harmony(distinct[-1], choice):
|
||||
distinct.append(choice)
|
||||
return tuple(right for left, right in zip(distinct, distinct[1:]) if left.span.chord and right.span.chord and generator.scales.selection.dominant(left.span.chord) and (generator.scales.selection.root_number(left.span.chord) - generator.scales.selection.root_number(right.span.chord)) % 12 == 7)
|
||||
|
||||
def anchor_backbones(identifier, start, end, release, register, choices, bar, seed):
|
||||
active = [c for c in choices if c.scale and c.span.start < end - release and c.span.end > start]
|
||||
if not active:
|
||||
return tuple([()])
|
||||
finish = min(end - release, active[-1].span.end)
|
||||
terminal_duration = min(fractions.Fraction(2), (finish - start) / 2)
|
||||
terminal_start = max(start, active[-1].span.start, finish - terminal_duration)
|
||||
terminal_duration = finish - terminal_start
|
||||
opening = max(start, active[0].span.start)
|
||||
moments = [(opening, 'opening')]
|
||||
meaningful = list(resolutions(active)) + [b for a, b in zip(active, active[1:]) if a.scale.pitches != b.scale.pitches]
|
||||
for choice in sorted(meaningful, key=lambda c: c.span.start):
|
||||
time = choice.span.start
|
||||
if time >= moments[-1][0] + bar * fractions.Fraction(3, 2) and time + bar < terminal_start and len(moments) < 4:
|
||||
moments.append((time, 'intermediate'))
|
||||
if terminal_start <= opening + fractions.Fraction(1, 2):
|
||||
moments = []
|
||||
moments.append((terminal_start, 'terminal'))
|
||||
states = [(0.0, (), intention) for intention in ('rising', 'falling', 'returning', 'repeated')]
|
||||
for index, (time, role) in enumerate(moments):
|
||||
choice = next(c for c in active if c.span.start <= time < c.span.end)
|
||||
chord_roles = generator.solo.harmonic.roles(choice.span.chord)
|
||||
eligible = (chord_roles.basic | chord_roles.specified) & choice.scale.pitches
|
||||
duration = terminal_duration if role == 'terminal' else fractions.Fraction(1, 2)
|
||||
latest = time if role == 'terminal' else min(time + fractions.Fraction(3, 2), choice.span.end - duration, terminal_start - duration)
|
||||
pitches = [generator.solo.types.scale_pitch(choice.scale, degree) for degree in range(-21, 28)]
|
||||
pitches = [p for p in pitches if register[0] <= p.number() <= register[1] and p.number() % 12 in eligible and generator.fretboard.single_positions_for_pitch(fractions.Fraction(p.number()))]
|
||||
desired = register[0] + (5 if index == 0 else 8 if role == 'terminal' else 11)
|
||||
following = []
|
||||
for cost, anchors, intention in states:
|
||||
for pitch in pitches:
|
||||
jump = abs(pitch.number() - anchors[-1].pitch.number()) if anchors else 0
|
||||
direction = 1 if intention == 'rising' or intention == 'returning' and role != 'terminal' else -1 if intention != 'repeated' else 0
|
||||
wrong_way = bool(anchors and (pitch.number() - anchors[-1].pitch.number()) * direction < 0)
|
||||
preferred = target_pitches(choice.span.chord, role == 'terminal')
|
||||
destination_motion = 0 if intention == 'repeated' else 4
|
||||
score = cost + abs(jump - destination_motion) / 12 + 0.8 * wrong_way + abs(pitch.number() - desired) / 36 + 0.3 * (pitch.number() % 12 not in chord_roles.basic) + 0.2 * (role == 'terminal' and pitch.number() % 12 not in preferred)
|
||||
anchor = generator.solo.types.Anchor(f'{identifier}_anchor_{index + 1}', time, latest, pitch, frozenset(eligible), register, duration, role, generator.harmony.symbol(choice.span.chord), False, finish if role == 'terminal' else None)
|
||||
following.append((score, (*anchors, anchor), intention))
|
||||
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], generator.solo.policy.stable_rank(seed, tuple(a.pitch for a in item[1]))), lambda item: (item[2], item[1][0].pitch.number()), generator.solo.policy.DEFAULT.anchor_lines))
|
||||
return tuple(dict.fromkeys(anchors for _, anchors, _ in states))
|
||||
|
||||
def plans(analysis: generator.solo.analysis.Analysis, measures: list[dict], choices, end: fractions.Fraction, part: str, sequences: generator.solo.sequences.Library, *, lead=False) -> tuple[generator.solo.types.PhrasePlan, ...]:
|
||||
boundaries = [fractions.Fraction()]
|
||||
for measure in measures:
|
||||
boundaries.append(min(end, boundaries[-1] + generator.model.fraction(measure['duration'])))
|
||||
head, contrast, _ = choose_motifs(analysis, sequences, boundaries[1])
|
||||
seed = generator.solo.policy.stable_rank(generator.solo.analysis.identity(analysis, measures, part), generator.solo.policy.SEED_NAMESPACE)
|
||||
activity = fractions.Fraction(str(generator.solo.policy.DEFAULT.acoustic_activity if part == 'acoustic' else generator.solo.policy.DEFAULT.electric_activity))
|
||||
result, index, passage_index, inventions = [], 0, 0, {}
|
||||
cadences = resolutions(choices)
|
||||
while index < len(measures) and boundaries[index] < end:
|
||||
start = boundaries[index]
|
||||
stops = sorted({min(index + size, len(measures)) for size in (4, 6, 8)})
|
||||
|
||||
def destination_rank(stop):
|
||||
finish = boundaries[stop]
|
||||
last = next((c for c in reversed(choices) if c.scale and c.span.start < finish and c.span.end > start), None)
|
||||
resolved = bool(last and any(start < c.span.start < finish and same_harmony(c, last) for c in cadences))
|
||||
dominant = bool(last and generator.scales.selection.dominant(last.span.chord))
|
||||
return (0 if resolved else 2 if dominant else 1), abs(stop - index - 4), finish
|
||||
|
||||
stop = min(stops, key=destination_rank)
|
||||
finish = boundaries[stop]
|
||||
if finish <= start:
|
||||
break
|
||||
passage_index += 1
|
||||
passage_id = f'{part}_passage_{passage_index}'
|
||||
passage_seed = generator.solo.policy.stable_rank(seed, (str(start), str(finish)))
|
||||
low, high = (55, 74) if part == 'acoustic' else (59, 79)
|
||||
phase = (start + finish) / (2 * end)
|
||||
register = (low + (5 if fractions.Fraction(1, 2) <= phase < fractions.Fraction(3, 4) else 0), high if phase >= fractions.Fraction(1, 2) else high - 4)
|
||||
register = generator.fretboard.single_register(register)
|
||||
bar = boundaries[index + 1] - start
|
||||
release = fractions.Fraction(1, 2) if decision(passage_seed, 'breathing') % 3 == 0 else fractions.Fraction()
|
||||
release = min(release, (finish - start) / 8)
|
||||
envelope = activity_passages(start, finish, release, activity, decision(passage_seed, 'activity') % 3)
|
||||
anchors = () if lead else anchor_backbones(passage_id, start, finish, release, register, choices, bar, passage_seed)
|
||||
# Prefer four-bar phrasing; extend when a sustained harmony crosses that seam.
|
||||
middle = min(index + 4, stop)
|
||||
before = next((c for c in reversed(choices) if c.span.start < boundaries[middle]), None)
|
||||
after = next((c for c in choices if c.span.start <= boundaries[middle] < c.span.end), None)
|
||||
extended = stop - index > 4 and same_harmony(before, after)
|
||||
# A bounded complete lead draft can express an extended phrase directly.
|
||||
# Computational note groups below do not require additional phrase ends.
|
||||
phrase_stops = [stop] if lead or extended or middle == stop else [middle, stop]
|
||||
local = tuple(c for c in choices if c.span.start < boundaries[phrase_stops[0]] and c.span.end > start)
|
||||
slot = decision(passage_seed, 'motif') % 3
|
||||
if part == 'acoustic':
|
||||
slot = 0 if passage_index <= 2 else slot
|
||||
if slot == 2 and 0 not in inventions:
|
||||
inventions[0] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed)
|
||||
motif = head if slot == 0 else contrast if slot == 1 else inventions[0]
|
||||
else:
|
||||
slot = 0 if passage_index == 1 else slot
|
||||
if slot < 2 and slot not in inventions:
|
||||
inventions[slot] = new_motif(sequences, bar, choices=local, register=register, seed=passage_seed, variant=slot)
|
||||
motif = head if slot == 2 else inventions[slot]
|
||||
phrase_start = start
|
||||
for phrase_stop in phrase_stops:
|
||||
phrase_end = boundaries[phrase_stop]
|
||||
terminal = phrase_end == finish
|
||||
treatment = 'lead' if lead else 'opening_reference' if motif.origin == 'melody' else 'invented'
|
||||
landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, min(v[i].pitch.number() for v in anchors), max(v[i].pitch.number() for v in anchors), 0, a.role) for i, a in enumerate(anchors[0])) if anchors and anchors[0] else ()
|
||||
result.append(generator.solo.types.PhrasePlan(f'{part}_phrase_{len(result) + 1}', phrase_start, phrase_end, motif, 'statement' if phrase_start == start else 'continue', treatment, None, register, activity, release if terminal else fractions.Fraction(), envelope, landmarks, 'breath' if terminal and release else 'sustain' if terminal else 'connected', 'closed' if terminal else 'open', passage_seed, passage_id, start, finish, anchors))
|
||||
phrase_start = phrase_end
|
||||
index = stop
|
||||
return tuple(result)
|
||||
@@ -1,66 +1,10 @@
|
||||
"""One curated, deterministic composition policy; all times are quarter-note beats."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
"""Physical/harmonic constants and semantic generation version."""
|
||||
import dataclasses, hashlib
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Policy:
|
||||
acoustic_activity: float = 1.125
|
||||
electric_activity: float = 1.125
|
||||
active_occupancy: float = 0.925
|
||||
arrival_occupancy: float = 0.9
|
||||
weights: tuple[tuple[str, float], ...] = (('activity', 4), ('occupancy', 4), ('landmarks', 3), ('register', 1), ('contour', 1), ('connection', 1), ('repetition', 1), ('movement', 0.5), ('harmonic_exposure', 2), ('draft_preservation', 3), ('development', 2), ('direction', 1), ('join_coherence', 2), ('ending_integration', 2))
|
||||
beam_width: int = 64
|
||||
extensions: int = 32
|
||||
retrieval: int = 32
|
||||
alternatives: int = 4
|
||||
long_note: float = 2.0
|
||||
stopping_note: float = 1.0
|
||||
suspension_preparation: float = 0.5
|
||||
suspension_resolution: float = 1.0
|
||||
replan_attempts: int = 2
|
||||
anchor_lines: int = 8
|
||||
draft_frontier: int = 16
|
||||
phrase_alternatives: int = 8
|
||||
join_alternatives: int = 8
|
||||
revision_sweeps: int = 2
|
||||
revision_joins: int = 2
|
||||
refinement_alternatives: int = 4
|
||||
join_window: float = 2.0
|
||||
activity_deadband: float = 1.0
|
||||
occupancy_deadband: float = 0.1
|
||||
lead_activity_range: tuple[float, float] = (1.0, 1.5)
|
||||
lead_occupancy_range: tuple[float, float] = (0.85, 1.0)
|
||||
source_variations: int = 8
|
||||
chorus_frontier: int = 8
|
||||
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
|
||||
theme_rhythm_alternatives: int = 8
|
||||
theme_destinations: int = 6
|
||||
theme_continuations: int = 8
|
||||
theme_route_jobs: int = 16
|
||||
settling_range: tuple[int, int] = (5, 7)
|
||||
history_bars: int = 16
|
||||
enclosure_targets: int = 4
|
||||
enclosure_routes: int = 4
|
||||
enclosure_frontier: int = 8
|
||||
enclosure_max_per_32_bars: int = 4
|
||||
composition_version: str = 'joint-arrivals-2'
|
||||
|
||||
# Preserve the former default root seeds while decoupling them from policy edits.
|
||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||
|
||||
DEFAULT = Policy()
|
||||
|
||||
def describe():
|
||||
values = dataclasses.asdict(DEFAULT)
|
||||
identity = hashlib.sha256(json.dumps(values, sort_keys=True).encode()).hexdigest()
|
||||
return {'fingerprint': identity, 'seed_namespace': SEED_NAMESPACE, 'parameters': values}
|
||||
|
||||
long_note: float = 2.
|
||||
stopping_note: float = 1.
|
||||
composition_version: str = 'phrase-composer-1'
|
||||
DEFAULT=Policy()
|
||||
def stable_rank(seed, value):
|
||||
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
|
||||
|
||||
@@ -1,935 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
import time
|
||||
|
||||
import generator.solo.drafts
|
||||
import generator.solo.features
|
||||
import generator.solo.routes
|
||||
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.rhythm
|
||||
import generator.scales.selection
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.motifs
|
||||
import generator.solo.placement
|
||||
import generator.solo.planning
|
||||
import generator.solo.scoring
|
||||
import generator.solo.sequences
|
||||
import generator.solo.policy
|
||||
import generator.solo.types
|
||||
|
||||
BEAM_WIDTH = generator.solo.policy.DEFAULT.beam_width
|
||||
CELL_LIMIT = generator.solo.policy.DEFAULT.extensions
|
||||
|
||||
class SearchExhausted(ValueError):
|
||||
pass
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Cell:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
patterns: tuple[tuple[str, tuple[tuple[fractions.Fraction, fractions.Fraction], ...]], ...]
|
||||
motif: generator.solo.types.Motif | None = None
|
||||
gesture: generator.solo.types.Gesture | None = None
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Candidate:
|
||||
notes: tuple[generator.solo.types.Note, ...]
|
||||
source: str
|
||||
treatment: str
|
||||
rank: tuple
|
||||
fidelity: float = 0
|
||||
treatments: tuple[generator.solo.types.HarmonicTreatment, ...] = ()
|
||||
|
||||
def rhythm_patterns(library: generator.rhythm.Library, meter: str, length: fractions.Fraction, activity: fractions.Fraction, offset=fractions.Fraction()):
|
||||
result = {}
|
||||
for snippet in library.for_meter(meter):
|
||||
for index, bar in enumerate(snippet.bars):
|
||||
spans = []
|
||||
for event in bar.events:
|
||||
if not event.sounding:
|
||||
continue
|
||||
if event.continuation:
|
||||
if spans and spans[-1][1] == event.onset:
|
||||
spans[-1] = (spans[-1][0], event.onset + event.duration)
|
||||
else:
|
||||
spans.append((event.onset, event.onset + event.duration))
|
||||
# Slice exact authored rhythm; later attacks outside the window do not
|
||||
# disqualify a useful prefix. A tied continuation never becomes an attack.
|
||||
pattern = tuple((start - offset, min(end - offset, length)) for start, end in spans if offset <= start < offset + length)
|
||||
result.setdefault(pattern, f'rhythm {snippet.id}:{index + 1}@{offset}+{length}')
|
||||
|
||||
def rank(item):
|
||||
pattern, identifier = item
|
||||
return abs(len(pattern) - length * activity), abs(sum(end - start for start, end in pattern) - length * fractions.Fraction(3, 4)), identifier
|
||||
|
||||
def family(item):
|
||||
pattern = item[0]
|
||||
return len(pattern), bool(pattern and pattern[0][0]), any(a.denominator > 1 for a, _ in pattern)
|
||||
|
||||
selected = generator.solo.sequences.diverse(result.items(), rank, family, 12)
|
||||
return tuple((identifier, pattern) for pattern, identifier in selected)
|
||||
|
||||
def cells(plan, measures, rhythms, choices=()):
|
||||
result, boundaries = [], [fractions.Fraction()]
|
||||
for measure in measures:
|
||||
boundaries.append(boundaries[-1] + generator.model.fraction(measure['duration']))
|
||||
start, music_end = plan.start, plan.end - plan.release
|
||||
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference')
|
||||
if protected:
|
||||
motif = plan.motif
|
||||
if not motif.notes or motif.duration > music_end - start:
|
||||
raise SearchExhausted(f'{plan.id}: protected motif rhythm does not fit the phrase')
|
||||
spans = tuple((n.start, n.end) for n in motif.notes)
|
||||
result.append(Cell(start, start + motif.duration, ((motif.id, spans),), motif))
|
||||
start += motif.duration
|
||||
while start < music_end:
|
||||
index = next(i for i in range(len(measures)) if boundaries[i] <= start < boundaries[i + 1])
|
||||
passage = next((p for p in plan.passages if p.start <= start < p.end), None)
|
||||
gesture = next((g for g in plan.gestures if g.start <= start < g.end), None)
|
||||
chord_end = next((c.span.end for c in choices if c.span.start <= start < c.span.end), music_end)
|
||||
end = min(music_end, boundaries[index + 1], passage.end if passage else music_end, gesture.end if gesture else music_end, chord_end)
|
||||
activity = passage.activity if passage else plan.activity
|
||||
patterns = rhythm_patterns(rhythms, measures[index]['time_signature'], end - start, activity, start - boundaries[index])
|
||||
patterns += (('hold', ((fractions.Fraction(), end - start),)), ('space', ()))
|
||||
if plan.target and start <= plan.target.latest and plan.target.earliest < end:
|
||||
onset = max(start, plan.target.earliest) - start
|
||||
patterns = (('arrival_and_release', ((onset, end - start),)), *patterns)
|
||||
motif = plan.motif if not result else None
|
||||
result.append(Cell(start, end, patterns, motif, gesture))
|
||||
start = end
|
||||
if plan.release:
|
||||
result.append(Cell(music_end, plan.end, (('planned_release', ()),)))
|
||||
return tuple(result)
|
||||
|
||||
@functools.lru_cache(maxsize=2048)
|
||||
def scale_options(scale, allowed, low, high):
|
||||
return tuple(generator.solo.types.scale_pitch(scale, index) for index in range(-21, 28) if low <= generator.solo.types.scale_pitch(scale, index).number() <= high and generator.solo.types.scale_pitch(scale, index).number() % 12 in allowed and generator.fretboard.single_positions_for_pitch(fractions.Fraction(generator.solo.types.scale_pitch(scale, index).number())))
|
||||
|
||||
def valid_notes(notes, choices):
|
||||
return all(n.pitch.number() % 12 in generator.solo.types.permitted(choices, n.start, n.end) for n in notes)
|
||||
|
||||
def options(choices, start, end, register):
|
||||
scale = next((c.scale for c in choices if c.span.start <= start < c.span.end), None)
|
||||
allowed = generator.solo.types.permitted(choices, start, end)
|
||||
return scale_options(scale, allowed, *generator.fretboard.single_register((register[0] - 7, register[1] + 5))) if scale and allowed else ()
|
||||
|
||||
def target_ok(notes, target):
|
||||
return target is None or any(target.earliest <= n.start <= target.latest and n.pitch.number() % 12 in target.pitches for n in notes)
|
||||
|
||||
def adapt_path(pitches, spans, choices, register):
|
||||
"""Connect actual pitches across collections without carrying a stale degree index."""
|
||||
states = [(0, ())]
|
||||
for index, ((start, end), intended) in enumerate(zip(spans, pitches)):
|
||||
available = options(choices, start, end, register)
|
||||
following = []
|
||||
for cost, path in states:
|
||||
desired = path[-1].pitch.number() + intended.number() - pitches[index - 1].number() if path else intended.number()
|
||||
for pitch in sorted(available, key=lambda p: (abs(p.number() - desired), p))[:2]:
|
||||
following.append((cost + abs(pitch.number() - desired), (*path, generator.solo.types.Note(start, end, pitch, role='adapted_path'))))
|
||||
states = sorted(following, key=lambda item: (item[0], tuple(n.pitch for n in item[1])))[:8]
|
||||
return [notes for _, notes in states]
|
||||
|
||||
def anchor_treatments(anchors, harmony):
|
||||
result = []
|
||||
for anchor in anchors:
|
||||
span = harmony.at(anchor.earliest)
|
||||
if span and anchor.pitch.number() % 12 in span.roles.specified:
|
||||
result.append(generator.solo.types.HarmonicTreatment(anchor.id + '_color', 'color', anchor.pitch.number(), anchor.earliest, anchor.hold_until or anchor.latest + anchor.duration))
|
||||
return tuple(result)
|
||||
|
||||
def rhythm_variants(cell, plan, state, anchors, reference):
|
||||
patterns = cell.patterns
|
||||
gesture = cell.gesture
|
||||
if cell.motif and plan.treatment in ('quote', 'exact_quote'):
|
||||
return [(name, tuple((cell.start + a, cell.start + b) for a, b in pattern), {}, False) for name, pattern in patterns]
|
||||
if gesture and gesture.treatment == 'rhythmic_reference' and state.motifs:
|
||||
# The identity is the rhythm actually heard, including its rests. Cropping
|
||||
# at a search boundary does not create a new rhythmic attack.
|
||||
pattern = tuple((max(cell.start, gesture.start + n.start) - cell.start, min(cell.end, gesture.start + n.end) - cell.start) for n in reference.notes if gesture.start + n.start < cell.end and gesture.start + n.end > cell.start)
|
||||
patterns = ((reference.id + ':rhythm', pattern),)
|
||||
due = [a for a in anchors if cell.start <= a.latest and a.earliest < cell.end and not generator.solo.harmonic.anchor_note(a, state.notes)]
|
||||
holding = next((a for a in anchors if state.last and state.last.role != 'quote' and a.hold_until and a.hold_until > cell.start and generator.solo.harmonic.anchor_note(a, state.notes, until=cell.start)), None)
|
||||
if holding:
|
||||
stop = min(cell.end, holding.hold_until)
|
||||
return [('anchor_sustain', ((cell.start, stop),), {cell.start: holding.pitch}, True)]
|
||||
result = []
|
||||
for name, pattern in patterns:
|
||||
spans = tuple((cell.start + a, cell.start + b) for a, b in pattern)
|
||||
variants = [(spans, {})]
|
||||
for anchor in due:
|
||||
following = []
|
||||
for current, forced in variants:
|
||||
onsets = [a for a, b in current if anchor.earliest <= a <= anchor.latest and b - a >= min(anchor.duration, cell.end - a)]
|
||||
if gesture and gesture.treatment == 'rhythmic_reference':
|
||||
times = onsets[:2]
|
||||
else:
|
||||
times = list(dict.fromkeys(onsets[:2] + [max(cell.start, anchor.earliest)]))
|
||||
for time in times:
|
||||
stop = min(cell.end, anchor.hold_until or time + anchor.duration)
|
||||
original_end = next((b for a, b in current if a == time), stop)
|
||||
stop = max(stop, original_end)
|
||||
if gesture and gesture.treatment == 'rhythmic_reference' and stop != original_end:
|
||||
continue
|
||||
overlaid = [(a, min(b, time)) for a, b in current if a < time]
|
||||
overlaid += [(time, stop)] + [(a, b) for a, b in current if a >= stop]
|
||||
following.append((tuple((a, b) for a, b in overlaid if b > a), {**forced, time: anchor.pitch}))
|
||||
variants = following
|
||||
result.extend((name, spans, forced, False) for spans, forced in variants)
|
||||
return result
|
||||
|
||||
def realize_route(spans, first, steps, forced, choices, plan, harmony, contracts, reference):
|
||||
"""Instantiate relative motion at actual pitches, adapting only at collection changes."""
|
||||
path = []
|
||||
for index, (start, end) in enumerate(spans):
|
||||
available = options(choices, start, end, plan.register)
|
||||
if not available:
|
||||
return None
|
||||
if start in forced:
|
||||
available = tuple(p for p in available if p.number() == forced[start].number())
|
||||
if end - start >= generator.solo.policy.DEFAULT.long_note:
|
||||
members = harmony.members(start, end)
|
||||
available = tuple(p for p in available if p.number() % 12 in members or any(t.pitch == p.number() and t.start <= start and t.end >= end for t in contracts))
|
||||
if not available:
|
||||
return None
|
||||
intended = first.number()
|
||||
if path:
|
||||
prior = path[-1].pitch.number()
|
||||
scale = harmony.at(start).scale
|
||||
step, semitones = steps[(index - 1) % len(steps)] if steps else (0, 0)
|
||||
try:
|
||||
intended = generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, prior) + step).number() if step is not None else prior + semitones
|
||||
except ValueError:
|
||||
# The old pitch is not in the new collection: preserve its sounding
|
||||
# coordinate and adapt its intended motion, never transfer degree IDs.
|
||||
intended = prior + semitones
|
||||
stopping = end - start >= generator.solo.policy.DEFAULT.stopping_note
|
||||
basic = harmony.at(start).roles.basic
|
||||
pitch = min(available, key=lambda p: (abs(p.number() - intended) + (0.6 if stopping and p.number() % 12 not in basic else 0), p))
|
||||
source = reference.notes[min(index, len(reference.notes) - 1)].sources if reference.notes else ()
|
||||
path.append(generator.solo.types.Note(start, end, pitch, source, 'motif'))
|
||||
return tuple(path)
|
||||
|
||||
def candidates(cell, plan, choices, sequences, state=None, anchors=(), harmony=None):
|
||||
state = state or generator.solo.types.ContinuationState(plan.passage_id)
|
||||
harmony = harmony or generator.solo.harmonic.Harmony(choices)
|
||||
reference = generator.solo.motifs.reference(plan, cell.gesture, state.motifs)
|
||||
treatment = cell.gesture.treatment if cell.gesture else plan.treatment if cell.motif else 'continue'
|
||||
contracts = tuple(dict.fromkeys((*state.treatments, *anchor_treatments(anchors, harmony))))
|
||||
protected = cell.motif and plan.treatment in ('quote', 'exact_quote')
|
||||
local_plan = dataclasses.replace(plan, start=cell.start, end=cell.end, release=fractions.Fraction(), passages=tuple(dataclasses.replace(p, start=max(cell.start, p.start), end=min(cell.end, p.end)) for p in plan.passages if p.start < cell.end and p.end > cell.start))
|
||||
evaluation = generator.solo.scoring.Evaluation(local_plan, choices)
|
||||
result = []
|
||||
for identifier, spans, forced, sustain in rhythm_variants(cell, plan, state, anchors, reference):
|
||||
active = tuple((a, b) for a, b in spans if generator.solo.types.permitted(choices, a, b))
|
||||
if active != spans:
|
||||
if protected:
|
||||
continue
|
||||
spans = active
|
||||
identifier += ':harmonic_fragment'
|
||||
if not spans:
|
||||
if not protected:
|
||||
result.append(Candidate((), identifier, 'space', (generator.solo.scoring.value(evaluation.features(())),), treatments=contracts))
|
||||
continue
|
||||
available = options(choices, *spans[0], plan.register)
|
||||
pending = next((t for t in state.treatments if t.kind == 'suspension' and t.deadline >= cell.start and state.last and state.last.pitch.number() == t.pitch and state.last.end == cell.start and not any(n.pitch.number() == t.resolution and n.start >= t.start for n in state.notes)), None)
|
||||
if pending:
|
||||
resolution = next((p for p in available if p.number() == pending.resolution), None)
|
||||
if resolution is None or spans[0][0] != cell.start:
|
||||
continue
|
||||
forced = {**forced, spans[0][0]: resolution}
|
||||
sustain = False
|
||||
last = state.last
|
||||
next_anchor = next((a for a in anchors if a.latest >= cell.start and not generator.solo.harmonic.anchor_note(a, state.notes)), None)
|
||||
destination = next_anchor.pitch.number() if next_anchor else last.pitch.number() if last else reference.notes[0].pitch.number()
|
||||
if protected:
|
||||
paths = []
|
||||
for octave in (0, 1, -1, 2, -2):
|
||||
notes = tuple(generator.solo.types.Note(a, b, dataclasses.replace(n.pitch, octave=n.pitch.octave + octave), n.sources, 'quote') for (a, b), n in zip(spans, cell.motif.notes))
|
||||
if valid_notes(notes, choices):
|
||||
paths.append((notes, f'quote octave {octave:+}'))
|
||||
elif sustain and last:
|
||||
paths = [((generator.solo.types.Note(*spans[0], last.pitch, role='sustain', continuation=True),), 'anchor_sustain')]
|
||||
else:
|
||||
near = last.pitch.number() if last else destination
|
||||
starts = sorted(available, key=lambda p: (abs(p.number() - near), p))[:3]
|
||||
if spans[0][0] in forced:
|
||||
starts = [forced[spans[0][0]]]
|
||||
scale = harmony.at(spans[0][0]).scale
|
||||
target = min(available, key=lambda p: abs(p.number() - destination))
|
||||
paths = []
|
||||
motif_steps = generator.solo.motifs.interval_steps(reference, harmony)
|
||||
gesture_start = cell.gesture.start if cell.gesture else cell.start
|
||||
heard = tuple(n for n in state.notes if n.start >= gesture_start)
|
||||
cursor = len(heard)
|
||||
if motif_steps and last:
|
||||
step, semitones = motif_steps[max(0, cursor - 1) % len(motif_steps)]
|
||||
try:
|
||||
expected = generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, last.pitch.number()) + step).number() if step is not None else last.pitch.number() + semitones
|
||||
except ValueError:
|
||||
expected = last.pitch.number() + semitones
|
||||
supported = min(available, key=lambda p: (abs(p.number() - expected), p))
|
||||
if supported not in starts:
|
||||
starts.append(supported)
|
||||
if motif_steps:
|
||||
motif_steps = motif_steps[cursor % len(motif_steps):] + motif_steps[:cursor % len(motif_steps)]
|
||||
for first in starts:
|
||||
displacement = generator.solo.types.scale_index(scale, target.number()) - generator.solo.types.scale_index(scale, first.number())
|
||||
if next_anchor and next_anchor.earliest > cell.end:
|
||||
displacement = round(displacement * float((cell.end - cell.start) / (next_anchor.earliest - cell.start)))
|
||||
direction = (displacement > 0) - (displacement < 0)
|
||||
routes = [(motif_steps, 'develop ' + reference.id)]
|
||||
retrieved = sequences.retrieve(len(spans), displacement, direction)
|
||||
selected = generator.solo.sequences.diverse(retrieved, lambda p: (abs(p.offsets()[-1] - displacement), p.sources), lambda p: generator.solo.sequences.family(p.steps), 4)
|
||||
for path in selected:
|
||||
steps = tuple((step, generator.solo.types.scale_pitch(scale, generator.solo.types.scale_index(scale, first.number()) + step).number() - first.number()) for step in path.steps)
|
||||
if motif_steps and treatment != 'rhythmic_reference' and not (cell.gesture and cell.gesture.relationship == 'close'):
|
||||
# Preserve identifying motion, then let the authored vocabulary
|
||||
# connect it toward the next anchor. The next cell uses this result.
|
||||
keep = min(2, len(steps))
|
||||
steps = tuple(motif_steps[i % len(motif_steps)] for i in range(keep)) + steps[keep:]
|
||||
routes.append((steps, 'sequences ' + ' + '.join(path.sources) + '; develop ' + reference.id))
|
||||
for steps, name in routes:
|
||||
notes = realize_route(spans, first, steps, forced, choices, plan, harmony, contracts, reference)
|
||||
if notes:
|
||||
paths.append((notes, name))
|
||||
if last and last.end == cell.start and spans[0][0] == cell.start and not protected:
|
||||
# An existing sustain is a real alternative, independent of reattack.
|
||||
for notes, name in list(paths):
|
||||
if notes[0].pitch.number() == last.pitch.number() and last.role != 'quote':
|
||||
paths.append(((dataclasses.replace(notes[0], pitch=last.pitch, continuation=True, role='sustain'), *notes[1:]), 'common_tone; ' + name))
|
||||
# Plan a prepared suspension from the incoming sound before constructing
|
||||
# its continuation. The contract survives subsequent search boundaries.
|
||||
if last and last.role != 'quote' and last.end == cell.start and cell.end - cell.start >= 1 and last.start <= cell.start - fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_preparation)):
|
||||
span = harmony.at(cell.start)
|
||||
prepared = last.pitch.number() % 12 in harmony.members(cell.start - fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_preparation)), cell.start)
|
||||
if prepared and span and span.scale and last.pitch.number() % 12 in span.scale.pitches and last.pitch.number() % 12 not in span.roles.basic:
|
||||
resolution_time = cell.start + fractions.Fraction(1, 2)
|
||||
resolving = [p for p in options(choices, resolution_time, min(cell.end, resolution_time + fractions.Fraction(1, 2)), plan.register) if p.number() % 12 in span.roles.basic and harmony.step(last.pitch.number(), p.number(), resolution_time)]
|
||||
for pitch in resolving:
|
||||
contract = generator.solo.types.HarmonicTreatment(f'{plan.passage_id}_suspension_{cell.start}_{pitch.number()}', 'suspension', last.pitch.number(), cell.start, resolution_time, pitch.number(), cell.start + fractions.Fraction(str(generator.solo.policy.DEFAULT.suspension_resolution)))
|
||||
for original, name in paths[:4]:
|
||||
tail = tuple(n for n in original if n.start > resolution_time)
|
||||
end = tail[0].start if tail else cell.end
|
||||
suspended = (generator.solo.types.Note(cell.start, resolution_time, last.pitch, role='sustain', continuation=True), generator.solo.types.Note(resolution_time, end, pitch, role='motif'), *tail)
|
||||
raw = tuple(dataclasses.replace(n, position=None) for n in state.notes)
|
||||
if not raw:
|
||||
continue
|
||||
combined = generator.solo.types.sounding_notes((*raw, *suspended))
|
||||
checked = harmony.evaluate(combined, (*contracts, contract), until=cell.end)
|
||||
if checked.valid:
|
||||
features = evaluation.features(tuple(dataclasses.replace(n, continuation=False) for n in suspended))
|
||||
features['harmonic_exposure'] = checked.exposure
|
||||
fidelity = generator.solo.scoring.motif_fidelity(tuple(n for n in combined if n.start >= (cell.gesture.start if cell.gesture else cell.start)), reference, treatment)
|
||||
features['fidelity'] = fidelity
|
||||
result.append(Candidate(suspended, identifier + '; prepared_suspension; ' + name, treatment, (generator.solo.scoring.value(features),), fidelity, (*contracts, contract)))
|
||||
for notes, name in paths:
|
||||
# Continuations are canonicalized with their incoming sounding event.
|
||||
raw = tuple(dataclasses.replace(n, position=None) for n in state.notes)
|
||||
combined = generator.solo.types.sounding_notes((*raw, *notes)) if raw or not notes[0].continuation else ()
|
||||
if not combined:
|
||||
continue
|
||||
harmonic = harmony.evaluate(combined, contracts, until=cell.end)
|
||||
if not harmonic.valid:
|
||||
continue
|
||||
local = tuple(dataclasses.replace(n, continuation=False) for n in notes)
|
||||
features = evaluation.features(local)
|
||||
gesture_notes = tuple(n for n in combined if n.start >= (cell.gesture.start if cell.gesture else cell.start))
|
||||
fidelity = (generator.solo.scoring.motif_fidelity(gesture_notes, reference, treatment) if len(gesture_notes) > 1 else 0.5) if cell.gesture or cell.motif else 0
|
||||
features['fidelity'] = fidelity
|
||||
features['harmonic_exposure'] = harmonic.exposure
|
||||
features['connection'] = min(1, abs(notes[0].pitch.number() - last.pitch.number()) / 12) if last else 0
|
||||
result.append(Candidate(notes, identifier + '; ' + name, name if protected else treatment, (generator.solo.scoring.value(features),), fidelity, contracts))
|
||||
unique = {}
|
||||
for candidate in sorted(result, key=lambda c: (c.rank, c.source, tuple(n.pitch for n in c.notes))):
|
||||
unique.setdefault((candidate.notes, candidate.treatments), candidate)
|
||||
return list(unique.values())
|
||||
|
||||
@dataclasses.dataclass
|
||||
class SearchContext:
|
||||
pools: dict = dataclasses.field(default_factory=dict)
|
||||
results: dict = dataclasses.field(default_factory=dict)
|
||||
resources: dict = dataclasses.field(default_factory=dict)
|
||||
revise: bool = True
|
||||
batch: int = 0
|
||||
references: tuple = ()
|
||||
stage: str = 'elaborated'
|
||||
diagnostics: dict = dataclasses.field(default_factory=lambda: {'searches': 0, 'cache_hits': 0, 'generated_candidates': 0, 'evaluated_extensions': 0, 'peak_states': 0, 'candidate_families': {}, 'surviving_families': {}})
|
||||
|
||||
def note_family(notes):
|
||||
return generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))) if notes else 'space'
|
||||
|
||||
def phrase_context(plan, incoming, notes):
|
||||
return generator.solo.drafts.canonical_context(notes, incoming, plan)
|
||||
|
||||
def evaluation_plan(plan, anchors):
|
||||
landmarks = tuple(generator.solo.types.Landmark(a.earliest, a.latest, a.pitch.number(), a.pitch.number(), name=a.role) for a in anchors)
|
||||
return dataclasses.replace(plan, start=plan.passage_start if plan.passage_start is not None else plan.start, end=plan.passage_end or plan.end, landmarks=landmarks)
|
||||
|
||||
def inherited_treatments(plan, incoming):
|
||||
if plan.passage_id and plan.passage_id == incoming.passage_id:
|
||||
return incoming.treatments
|
||||
retained = []
|
||||
for treatment in incoming.treatments:
|
||||
future = treatment.start >= plan.start
|
||||
color = treatment.kind == 'color' and incoming.last and treatment.end > incoming.last.start
|
||||
pending = treatment.kind == 'suspension' and incoming.last and incoming.last.pitch.number() == treatment.pitch and incoming.last.end > treatment.start and not any(n.start >= treatment.start and n.pitch.number() == treatment.resolution for n in incoming.notes)
|
||||
if future or color or pending:
|
||||
retained.append(treatment)
|
||||
# Completed contracts remain in their original phrase reports; only the
|
||||
# sounding tail and unresolved obligations require context in a new passage.
|
||||
return tuple(retained)
|
||||
|
||||
def draft_pitch_paths(rhythm, plan, choices, harmony, sequences, incoming, context):
|
||||
"""Instantiate a complete timed line. Boundaries here are musical groups."""
|
||||
F = fractions.Fraction
|
||||
contracts = tuple(dict.fromkeys((*inherited_treatments(plan, incoming), *plan.treatments, *anchor_treatments(rhythm.anchors, harmony))))
|
||||
# Cost, actual pitches so far, and the identity of the introduced statement.
|
||||
states = [(0.0, (), rhythm.reference.notes if isinstance(rhythm.reference, generator.solo.types.RealizedMotif) else ())]
|
||||
next_anchor = [next((a for a in rhythm.anchors if a.latest >= s.start), None) for s in rhythm.slots]
|
||||
for index, slot in enumerate(rhythm.slots):
|
||||
available = options(choices, slot.start, slot.end, plan.register)
|
||||
pending = next((t for t in contracts if t.kind == 'suspension' and t.deadline is not None and incoming.last and incoming.last.pitch.number() == t.pitch and incoming.last.end == slot.start and t.start <= slot.start <= t.deadline and not any(n.start >= t.start and n.pitch.number() == t.resolution for n in incoming.notes)), None)
|
||||
if pending:
|
||||
available = tuple(p for p in available if p.number() == pending.resolution)
|
||||
if slot.forced:
|
||||
available = tuple(p for p in available if p == slot.forced)
|
||||
quoted = slot.protected and plan.treatment in ('quote', 'exact_quote')
|
||||
if not quoted and slot.end - slot.start >= generator.solo.policy.DEFAULT.long_note:
|
||||
members = harmony.members(slot.start, slot.end)
|
||||
available = tuple(p for p in available if p.number() % 12 in members or any(t.pitch == p.number() and t.start <= slot.start and t.end >= slot.end for t in contracts))
|
||||
if not available:
|
||||
return (), f'{slot.id}: no valid pitch for sounding interval {slot.start}-{slot.end}'
|
||||
following = []
|
||||
group = rhythm.groups[slot.group]
|
||||
local_index = sum(s.group == slot.group for s in rhythm.slots[:index])
|
||||
remaining = sum(s.start >= slot.start and (not next_anchor[index] or s.start <= next_anchor[index].latest) for s in rhythm.slots[index:])
|
||||
span = harmony.at(slot.start)
|
||||
for cost, notes, home in states:
|
||||
prior = notes[-1] if notes else incoming.last
|
||||
reference = home if home and slot.group else rhythm.reference.notes
|
||||
if local_index >= 3 and group.operation in ('extend', 'extend_diminished', 'answer', 'continue'):
|
||||
previous_ids = {s.id for s in rhythm.slots if s.group == slot.group - 1}
|
||||
recent = tuple(n for n in notes if n.event_id in previous_ids) if slot.group else incoming.development.notes if incoming.development else ()
|
||||
reference = recent or reference
|
||||
contour = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(reference, reference[1:]))
|
||||
step = contour[(max(0, slot.reference - 1)) % len(contour)] if contour else 0
|
||||
if slot.reference == 0 and local_index == 0:
|
||||
# A group continues the incoming line; it does not teleport to the
|
||||
# source motif's register or to the center of a fresh bar.
|
||||
step = contour[-1] if contour and group.operation == 'extend' else 0
|
||||
expected = prior.pitch.number() + step if prior else rhythm.reference.notes[0].pitch.number()
|
||||
anchor = next_anchor[index]
|
||||
target = anchor.pitch.number() if anchor else expected
|
||||
# Theme invention follows a complete paired rhythmic/interval idea.
|
||||
# Authored sequence routes are instantiated as whole alternatives later.
|
||||
route = expected if not anchor else target
|
||||
ranked = sorted(available, key=lambda p: (abs(p.number() - expected), p))[:3]
|
||||
if available:
|
||||
bridge = min(available, key=lambda p: (abs(p.number() - route), p))
|
||||
if bridge not in ranked:
|
||||
ranked.append(bridge)
|
||||
for pitch in ranked:
|
||||
direction = (pitch.number() > prior.pitch.number()) - (pitch.number() < prior.pitch.number()) if prior else 0
|
||||
intended = (step > 0) - (step < 0)
|
||||
identifying = bool(local_index in (1, 2) and group.operation not in ('close', 'answer') and not slot.forced)
|
||||
jump = abs(pitch.number() - prior.pitch.number()) if prior else 0
|
||||
stopping = slot.end - slot.start >= generator.solo.policy.DEFAULT.stopping_note
|
||||
motion_cost = abs(pitch.number() - expected) / 12 * (1.5 if identifying else 0.35)
|
||||
direction_cost = max(0, abs(pitch.number() - target) - 2 * max(0, remaining - 1)) / 12 if anchor else 0
|
||||
exposure = 0.6 if stopping and span and pitch.number() % 12 not in span.roles.basic and not quoted else 0
|
||||
supported_leap = reference and abs(step) >= 3 and abs(pitch.number() - expected) <= 2
|
||||
score = cost + motion_cost + direction_cost + (0 if supported_leap else max(0, jump - 5) / 12) + exposure
|
||||
sources = reference[min(slot.reference, len(reference) - 1)].sources if reference else ()
|
||||
note = generator.solo.types.Note(slot.start, slot.end, pitch, sources, 'quote' if quoted else 'motif', event_id=slot.id)
|
||||
path = (*notes, note)
|
||||
identity = home
|
||||
if not identity and (index + 1 == len(rhythm.slots) or rhythm.slots[index + 1].group != slot.group):
|
||||
identity = tuple(n for n in path if n.event_id in {s.id for s in rhythm.slots if s.group == slot.group})
|
||||
following.append((score, path, identity))
|
||||
if not following:
|
||||
return (), f'{group.id}: identifying motion cannot reach the selected anchor within the draft budget'
|
||||
boundary = index + 1 == len(rhythm.slots) or rhythm.slots[index + 1].group != slot.group
|
||||
limit = generator.solo.policy.DEFAULT.draft_frontier if boundary else BEAM_WIDTH
|
||||
# Relevant home/contour alternatives survive; no barline participates.
|
||||
states = list(generator.solo.sequences.diverse(following, lambda item: (item[0], tuple(n.pitch for n in item[1])), lambda item: (tuple(n.pitch for n in item[2][:3]), item[1][-1].pitch), limit))
|
||||
context.diagnostics['evaluated_extensions'] += len(following)
|
||||
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
|
||||
return tuple((notes, cost / max(1, len(notes)), contracts) for cost, notes, _ in states[:4]), ''
|
||||
|
||||
def make_identity(identifier, parent, notes, start, end, relationship, harmony):
|
||||
local = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start, position=None) for n in notes)
|
||||
degrees = tuple(dict(harmony.at(n.start).roles.degrees).get(n.pitch.number() % 12, ()) for n in notes)
|
||||
return generator.solo.types.RealizedMotif(identifier, parent, local, end - start, relationship, 'developed_rhythm', degrees, start)
|
||||
|
||||
def make_draft(rhythm, notes, plan, harmony, incoming):
|
||||
transforms, structural, protected = [], [], []
|
||||
home = incoming.home if rhythm.relationship not in ('statement', 'contrast') else None
|
||||
if rhythm.relationship == 'recall' and isinstance(rhythm.reference, generator.solo.types.RealizedMotif):
|
||||
home = rhythm.reference
|
||||
parent = incoming.development.id if incoming.development and home else rhythm.reference.id
|
||||
for index, group in enumerate(rhythm.groups):
|
||||
ids = {s.id for s in rhythm.slots if s.group == index}
|
||||
actual = tuple(n for n in notes if n.event_id in ids)
|
||||
if not actual:
|
||||
continue
|
||||
next_start = rhythm.groups[index + 1].start if index + 1 < len(rhythm.groups) else group.end
|
||||
start, end = group.start, max(min(group.end, next_start), actual[-1].end)
|
||||
identity = make_identity(group.id, parent, actual, start, end, group.operation, harmony)
|
||||
if home is None:
|
||||
home = identity
|
||||
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(actual, actual[1:]))
|
||||
# Closing simplification explicitly releases the characteristic core.
|
||||
preserves = ()
|
||||
if plan.treatment == 'rhythmic_reference':
|
||||
preserves = ('rhythm',)
|
||||
transforms.append(generator.solo.types.Transformation(group.id, parent, home.id, group.operation, start, end, tuple(n.event_id for n in actual), tuple((n.start - start, n.end - start) for n in actual), directions, preserves, group.source))
|
||||
parent = identity.id
|
||||
protected.extend(s.id for s in rhythm.slots if s.protected)
|
||||
for anchor in rhythm.anchors:
|
||||
note = generator.solo.harmonic.anchor_note(anchor, notes)
|
||||
if note:
|
||||
structural.append(note.event_id)
|
||||
joins = tuple(t.start for t in transforms[1:])
|
||||
return generator.solo.types.MelodicDraft(rhythm.id, plan.start, plan.end, notes, tuple(transforms), tuple(dict.fromkeys(structural)), tuple(protected), rhythm.anchors, home, rhythm.relationship, joins, reference=rhythm.reference.id, reference_motif=rhythm.reference)
|
||||
|
||||
def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edits=()):
|
||||
valid, _ = generator.solo.drafts.contract_valid(draft, notes)
|
||||
if not valid:
|
||||
return None
|
||||
placed = generator.solo.placement.place(notes, tempo, incoming.last, harmony.profile)
|
||||
if placed is None:
|
||||
return None
|
||||
travel, notes = placed
|
||||
try:
|
||||
canonical = phrase_context(plan, incoming, notes)
|
||||
except ValueError:
|
||||
return None
|
||||
complete = plan.end == (plan.passage_end or plan.end)
|
||||
checked = harmony.evaluate(canonical, contracts, until=plan.end, complete=complete, enclosures=draft.enclosures, expressions=draft.expressions)
|
||||
valid, _ = generator.solo.harmonic.anchors_valid(draft.anchors, canonical, plan.end, complete=complete, closure=plan.closure)
|
||||
if not checked.valid or not valid or not target_ok(notes, plan.target):
|
||||
return None
|
||||
shared = bool(plan.passage_id and incoming.passage_id == plan.passage_id)
|
||||
score_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
|
||||
musical = None
|
||||
if plan.treatment == 'thematic_recomposition':
|
||||
musical = generator.solo.scoring.musical(plan,draft,notes,incoming,harmony,travel=travel)
|
||||
if not musical.valid:return None
|
||||
score = musical.total
|
||||
else:
|
||||
evaluation_key = (plan, draft.anchors)
|
||||
if evaluation_key not in harmony.evaluations:
|
||||
harmony.evaluations[evaluation_key] = generator.solo.scoring.Evaluation(evaluation_plan(plan, draft.anchors), harmony.choices, plan.end)
|
||||
evaluation = harmony.evaluations[evaluation_key]
|
||||
if not evaluation.feasible(score_notes):
|
||||
return None
|
||||
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0))
|
||||
features.update(generator.solo.drafts.relationship_features(draft, notes))
|
||||
if draft.lead_identity or draft.source_phrase:
|
||||
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)]
|
||||
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))
|
||||
# Fidelity is checked against explicit transformed identities, not against
|
||||
# the source head as one undifferentiated phrase-long quotation.
|
||||
features.pop('fidelity', None)
|
||||
features['harmonic_exposure'] = checked.exposure
|
||||
intended_repeats = 0
|
||||
if draft.home:
|
||||
reference = draft.home.notes
|
||||
for transform in draft.transformations:
|
||||
actual = tuple(n for n in notes if n.event_id in transform.events)
|
||||
for i, (a, b, c) in enumerate(zip(actual, actual[1:], actual[2:])):
|
||||
expected = tuple(reference[j % len(reference)].pitch for j in (i, i + 1, i + 2)) if reference else ()
|
||||
if a.pitch == b.pitch == c.pitch and len(expected) == 3 and expected[0] == expected[1] == expected[2]:
|
||||
intended_repeats += 1
|
||||
features['repetition'] = max(0, features['repetition'] - intended_repeats / max(1, len(score_notes) - 2))
|
||||
score = generator.solo.scoring.value(features)
|
||||
motifs = list(incoming.motifs) if shared else []
|
||||
decisions = []
|
||||
for transform in draft.transformations:
|
||||
actual = tuple(n for n in notes if n.event_id in transform.events or transform.start <= n.start < transform.end and n.event_id not in {e for t in draft.transformations for e in t.events})
|
||||
if not actual:
|
||||
continue
|
||||
identity = make_identity(transform.id, transform.parent, actual, transform.start, transform.end, transform.operation, harmony)
|
||||
motifs.append(identity)
|
||||
treatment = f'quote octave {round((actual[0].pitch.number() - plan.motif.notes[0].pitch.number()) / 12):+}' if actual[0].role == 'quote' else transform.operation
|
||||
decisions.append(generator.solo.types.Decision(min(n.start for n in actual), max(n.end for n in actual), 'phrase draft ' + draft.id + '; ' + transform.operation, treatment, actual, 0, transform.id, transform.parent))
|
||||
# Output ordering follows absolute events, even when a transformation's tail
|
||||
# was reopened by an overlapping extension.
|
||||
decisions.sort(key=lambda d: d.start)
|
||||
emitted = tuple(n for d in decisions for n in d.notes)
|
||||
if emitted != notes:
|
||||
return None
|
||||
home = next((m for m in motifs if draft.home and m.id == draft.home.id), draft.home)
|
||||
themes = tuple(dict.fromkeys((*incoming.themes, *((home,) if home else ()))))
|
||||
state_notes = canonical if shared or notes and notes[0].continuation else generator.solo.drafts.local_sounding(notes)
|
||||
last = state_notes[-1] if state_notes else incoming.last
|
||||
state = generator.solo.types.ContinuationState(plan.passage_id, state_notes, tuple(motifs), contracts,
|
||||
tuple(a.id for a in draft.anchors if generator.solo.harmonic.anchor_note(a, canonical)),
|
||||
(notes[-1].pitch.number() > notes[0].pitch.number()) - (notes[-1].pitch.number() < notes[0].pitch.number()) if notes else incoming.direction,
|
||||
last, 0, (), travel + (incoming.travel if shared else 0), home, motifs[-1] if motifs else incoming.development, themes, draft.anchors, plan.ending)
|
||||
actual_plan = dataclasses.replace(plan, relationship=draft.relationship)
|
||||
return generator.solo.types.Phrase(actual_plan, tuple(decisions), score, state, draft.anchors, draft, edits, evaluation=musical)
|
||||
|
||||
def legacy_target_plan(plan, choices):
|
||||
if not plan.target or plan.anchor_options:
|
||||
return plan
|
||||
target = plan.target
|
||||
duration = fractions.Fraction(1, 2)
|
||||
arrival = next((p for p in plan.passages if p.start <= target.earliest < p.end and p.intent == 'sustained_arrival'), None)
|
||||
if arrival and arrival.activity <= fractions.Fraction(1, 4):
|
||||
duration = arrival.end - target.earliest
|
||||
pitches = options(choices, target.earliest, target.earliest + duration, plan.register)
|
||||
anchors = tuple((generator.solo.types.Anchor(plan.id + '_target', target.earliest, target.latest, pitch, target.pitches, plan.register, duration, 'terminal', target.chord, True, target.earliest + duration),) for pitch in pitches if pitch.number() % 12 in target.pitches)
|
||||
return dataclasses.replace(plan, anchor_options=anchors)
|
||||
|
||||
def emitted_notes(phrase):
|
||||
return tuple(n for d in phrase.decisions for n in d.notes)
|
||||
|
||||
def window_variants(phrase, low, high, harmony, sequences, kind):
|
||||
"""Two fixed outside endpoints bound edits to the existing melody."""
|
||||
notes = emitted_notes(phrase)
|
||||
protected = set((*phrase.draft.structural, *phrase.draft.protected, *(e for f in phrase.draft.features for e in f.events), *(n.event_id for r in phrase.routes for n in r.notes)))
|
||||
inside = [i for i, n in enumerate(notes) if low <= n.start and n.end <= high]
|
||||
editable = [i for i in inside if notes[i].event_id not in protected]
|
||||
if not editable:
|
||||
return ()
|
||||
candidates = []
|
||||
left, right = max(0, inside[0] - 1), min(len(notes) - 1, inside[-1] + 1)
|
||||
# Compare complete routes through both fixed endpoints. Reject an invalid
|
||||
# path as a unit; never substitute nearest pitches for individual steps.
|
||||
for route in generator.solo.routes.candidates(notes[left:right + 1], protected, harmony, sequences, phrase.plan.register):
|
||||
candidates.append(((*notes[:left], *route.notes, *notes[right + 1:]), 'complete route ' + ' + '.join(route.sources)))
|
||||
for index in editable:
|
||||
note = notes[index]
|
||||
if index + 1 < len(notes) and notes[index + 1].start <= high:
|
||||
after = notes[index + 1]
|
||||
if after.event_id not in protected and note.end == after.start and note.pitch == after.pitch:
|
||||
candidates.append(((*notes[:index], dataclasses.replace(note, end=after.end), *notes[index + 2:]), 'shared common-tone arrival'))
|
||||
if kind == 'join' and after.event_id not in protected and note.end == after.start and note.end - note.start > fractions.Fraction(1, 2):
|
||||
# The tail and pickup move together, preserving total sounding time.
|
||||
time = after.start - fractions.Fraction(1, 2)
|
||||
candidates.append(((*notes[:index], dataclasses.replace(note, end=time), dataclasses.replace(after, start=time), *notes[index + 2:]), 'reopen tail into pickup'))
|
||||
return tuple(candidates)
|
||||
|
||||
def improve_window(phrase, incoming, harmony, sequences, tempo, low, high, kind, context):
|
||||
original = emitted_notes(phrase)
|
||||
viable = [phrase]
|
||||
proposals = window_variants(phrase, low, high, harmony, sequences, kind)
|
||||
# Keep distinct rhythmic and pitch revisions; no generic unlimited search.
|
||||
proposals = generator.solo.sequences.diverse(proposals, lambda p: (abs(len(p[0]) - len(original)), p[1], tuple(n.pitch for n in p[0])), lambda p: (p[1].split()[0], len(p[0])), generator.solo.policy.DEFAULT.join_alternatives)
|
||||
for notes, source in proposals:
|
||||
if tuple((n.start, n.end, n.pitch, n.continuation) for n in notes) == tuple((n.start, n.end, n.pitch, n.continuation) for n in original):
|
||||
continue
|
||||
changed_before = tuple(n.event_id for n in original if n not in notes)
|
||||
changed_after = tuple(n.event_id for n in notes if n not in original)
|
||||
edit = generator.solo.types.DraftEdit(kind, low, high, changed_before, changed_after, source)
|
||||
revised = checked_phrase(phrase.plan, phrase.draft, notes, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
|
||||
context.diagnostics[kind + '_attempts'] = context.diagnostics.get(kind + '_attempts', 0) + 1
|
||||
if revised:
|
||||
viable.append(dataclasses.replace(revised, initial=phrase.initial, refined=phrase.refined, routes=phrase.routes))
|
||||
chosen = min(viable, key=lambda p: (p.score, len(p.edits), tuple((n.pitch, n.start, n.end) for n in emitted_notes(p))))
|
||||
if chosen is not phrase:
|
||||
context.diagnostics[kind + '_accepted'] = context.diagnostics.get(kind + '_accepted', 0) + 1
|
||||
return chosen
|
||||
|
||||
def rebase_draft(draft, notes, harmony):
|
||||
"""Reassign edited events by absolute group time, retaining source contracts."""
|
||||
notes = tuple(dataclasses.replace(n, position=None) for n in notes)
|
||||
groups = []
|
||||
original_groups = {g.id: g for g in draft.transformations}
|
||||
for i, group in enumerate(draft.transformations):
|
||||
low = draft.start if i == 0 else group.start
|
||||
high = draft.transformations[i + 1].start if i + 1 < len(draft.transformations) else draft.end
|
||||
actual = tuple(n for n in notes if low <= n.start < high)
|
||||
if actual:
|
||||
parent = group.parent
|
||||
while parent in original_groups and parent not in {g.id for g in groups}:
|
||||
parent = original_groups[parent].parent
|
||||
groups.append(dataclasses.replace(group, parent=parent, events=tuple(n.event_id for n in actual), rhythm=group.rhythm if group.preserves else tuple((n.start - group.start, n.end - group.start) for n in actual)))
|
||||
home = draft.home
|
||||
if home and groups and home.id == groups[0].id:
|
||||
actual = tuple(n for n in notes if n.event_id in groups[0].events)
|
||||
home = make_identity(home.id, home.parent, actual, groups[0].start, groups[0].end, home.relationship, harmony)
|
||||
return dataclasses.replace(draft, notes=notes, transformations=tuple(groups), home=home)
|
||||
|
||||
def replace_idea(phrase, donor, low, high, incoming, harmony, tempo):
|
||||
"""A whole two-bar idea is editable until its identifying features are selected."""
|
||||
original = emitted_notes(phrase)
|
||||
if any(n.start < low < n.end or n.start < high < n.end for n in original):
|
||||
return None
|
||||
replacement = tuple(n for n in emitted_notes(donor) if low <= n.start and n.end <= high)
|
||||
if not replacement or tuple((n.start, n.end, n.pitch) for n in replacement) == tuple((n.start, n.end, n.pitch) for n in original if low <= n.start < high):
|
||||
return None
|
||||
protected = set((*phrase.draft.structural, *phrase.draft.protected))
|
||||
identifiers = {}
|
||||
for before in original:
|
||||
if before.event_id in protected and low <= before.start < high:
|
||||
after = next((n for n in replacement if (n.start, n.end, n.pitch) == (before.start, before.end, before.pitch)), None)
|
||||
if not after:
|
||||
return None
|
||||
identifiers[after.event_id] = before.event_id
|
||||
replacement = tuple(dataclasses.replace(n, event_id=identifiers.get(n.event_id, n.event_id + '_replacement'), position=None) for n in replacement)
|
||||
notes = tuple(n for n in original if n.end <= low) + replacement + tuple(n for n in original if n.start >= high)
|
||||
valid, _ = generator.solo.drafts.contract_valid(phrase.draft, notes)
|
||||
if not valid:
|
||||
return None
|
||||
draft = rebase_draft(phrase.draft, notes, harmony)
|
||||
edit = generator.solo.types.DraftEdit('replace_idea', low, high, tuple(n.event_id for n in original if low <= n.start < high), tuple(n.event_id for n in replacement), donor.draft.reference + '; ' + donor.draft.id)
|
||||
return checked_phrase(phrase.plan, draft, notes, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
|
||||
|
||||
def refine(phrase, pool, incoming, harmony, sequences, tempo, context, search=True):
|
||||
initial = phrase.draft
|
||||
for low in (phrase.plan.start, phrase.plan.start + 8) if search else ():
|
||||
high = min(phrase.plan.end, low + 8)
|
||||
if high - low < 8 or phrase.draft.protected:
|
||||
continue
|
||||
viable = [phrase]
|
||||
for donor in pool:
|
||||
if donor.draft.id == phrase.draft.id:
|
||||
continue
|
||||
context.diagnostics['idea_replacements_tried'] = context.diagnostics.get('idea_replacements_tried', 0) + 1
|
||||
revised = replace_idea(phrase, donor, low, high, incoming, harmony, tempo)
|
||||
if revised:
|
||||
viable.append(revised)
|
||||
chosen = min(viable, key=lambda p: (p.score, len(p.edits), p.draft.id))
|
||||
if chosen is not phrase:
|
||||
context.diagnostics['idea_replacements_selected'] = context.diagnostics.get('idea_replacements_selected', 0) + 1
|
||||
phrase = chosen
|
||||
if search and context.revise:
|
||||
phrase = revise_joins(phrase, incoming, harmony, sequences, tempo, context)
|
||||
notes = emitted_notes(phrase)
|
||||
draft = rebase_draft(phrase.draft, notes, harmony)
|
||||
# Only now commit a selected, inspectable relationship for elaboration.
|
||||
features = generator.solo.features.identifying_features(notes, draft.reference)
|
||||
draft = dataclasses.replace(draft, features=features)
|
||||
# Rebuild lineage after group removal or replacement, including when no run
|
||||
# is selected afterward. The introduced home is the actual refined tune.
|
||||
rebuilt = checked_phrase(phrase.plan, draft, notes, phrase.state.treatments, incoming, harmony, tempo, phrase.edits)
|
||||
if rebuilt is None:
|
||||
raise ValueError(phrase.plan.id + ': refined theme lost a committed contract')
|
||||
return dataclasses.replace(rebuilt, initial=initial, refined=draft)
|
||||
|
||||
def elaborate(phrase, incoming, harmony, sequences, tempo, context):
|
||||
started = time.monotonic()
|
||||
protected = set((*phrase.draft.structural, *phrase.draft.protected, *(e for f in phrase.draft.features for e in f.events)))
|
||||
if phrase.draft.protected:
|
||||
return phrase
|
||||
# Elaborate selected approaches in the existing tune, at most two per phrase.
|
||||
notes = emitted_notes(phrase)
|
||||
windows = []
|
||||
for right in range(2, len(notes)):
|
||||
if notes[right].event_id not in protected and right != len(notes) - 1:
|
||||
continue
|
||||
left = next((i for i in range(right - 1, -1, -1) if notes[right].start - notes[i].start >= 4), None)
|
||||
if left is not None and notes[right].start - notes[left].start <= 4:
|
||||
windows.append((notes[left].start, notes[right].start))
|
||||
limit = max(1, min(2, int((phrase.plan.end - phrase.plan.start) / 16))) if phrase.draft.lead_identity else 2
|
||||
if phrase.draft.lead_identity:
|
||||
intended = {phrase.plan.start + a for reference in context.references for a, _ in reference.runs}
|
||||
windows.sort(key=lambda window: (sum(n.event_id in protected for n in notes if window[0] <= n.start < window[1]), window[0] not in intended, window))
|
||||
selected_windows = windows[:limit]
|
||||
else:
|
||||
selected_windows = windows[-limit:]
|
||||
for low, high in selected_windows:
|
||||
if any(low < r.destination.end and high > r.notes[0].start for r in phrase.routes):
|
||||
continue
|
||||
notes = emitted_notes(phrase)
|
||||
left = next(i for i, n in enumerate(notes) if n.start == low)
|
||||
right = next(i for i, n in enumerate(notes) if n.start == high)
|
||||
patterns = [notes[left:right + 1]]
|
||||
# A full approach rhythm, preserving every committed event. Existing
|
||||
# silence remains a candidate; short-note motion is never compulsory.
|
||||
moving = []
|
||||
timepoint = low
|
||||
while timepoint < high:
|
||||
original = next((n for n in notes[left:right] if n.start == timepoint), None)
|
||||
end = min(high, timepoint + fractions.Fraction(1, 2))
|
||||
moving.append(dataclasses.replace(original, end=end, position=None) if original else generator.solo.types.Note(timepoint, end, notes[left].pitch, role='run', event_id=f'route_{phrase.draft.id}_{timepoint}'))
|
||||
timepoint = end
|
||||
patterns.append((*moving, notes[right]))
|
||||
for reference in context.references:
|
||||
for a, b in reference.runs:
|
||||
if high - low != b - a:
|
||||
continue
|
||||
profile = tuple(n for n in reference.decorated if a <= n.start and n.end <= b)
|
||||
timed = []
|
||||
for index, event in enumerate(profile):
|
||||
start, end = low + event.start - a, low + event.end - a
|
||||
original = next((n for n in notes[left:right] if n.start == start), None)
|
||||
timed.append(dataclasses.replace(original, end=end, position=None) if original else generator.solo.types.Note(start, end, notes[left].pitch, (reference.id,), 'run', event_id=f'profile_{phrase.draft.id}_{start}'))
|
||||
if timed:
|
||||
patterns.append((*timed, notes[right]))
|
||||
viable = [phrase]
|
||||
for pattern in dict.fromkeys(patterns):
|
||||
# Fixed events retain their duration and attack identity as well as pitch.
|
||||
if any(not any((n.event_id, n.start, n.end) == (p.event_id, p.start, p.end) for p in pattern) for n in notes[left:right + 1] if n.event_id in protected):
|
||||
continue
|
||||
for route in generator.solo.routes.candidates(pattern, protected, harmony, sequences, phrase.plan.register, 'approach the theme destination'):
|
||||
if phrase.draft.lead_identity:
|
||||
# A run must actually contain connected eighth-note movement;
|
||||
# relabeling the unchanged quarter-note answer is not elaboration.
|
||||
run = peak = 0
|
||||
previous_end = None
|
||||
for note in route.notes:
|
||||
eighth = note.end - note.start == fractions.Fraction(1, 2)
|
||||
run = run + 1 if eighth and previous_end == note.start else int(eighth)
|
||||
peak = max(peak, run)
|
||||
previous_end = note.end
|
||||
if peak < 4:
|
||||
continue
|
||||
actual = (*notes[:left], *route.notes, *notes[right + 1:])
|
||||
if phrase.draft.lead_identity and len(actual) > generator.solo.policy.DEFAULT.lead_activity_range[1] * float(phrase.plan.end - phrase.plan.start):
|
||||
continue
|
||||
context.diagnostics['complete_routes_tried'] = context.diagnostics.get('complete_routes_tried', 0) + 1
|
||||
if not generator.solo.drafts.contract_valid(phrase.draft, actual)[0]:
|
||||
continue
|
||||
draft = rebase_draft(phrase.draft, actual, harmony)
|
||||
edit = generator.solo.types.DraftEdit('complete_route', low, notes[right].end, tuple(n.event_id for n in notes[left:right + 1]), tuple(n.event_id for n in route.notes), ' + '.join(route.sources))
|
||||
revised = checked_phrase(phrase.plan, draft, actual, phrase.state.treatments, incoming, harmony, tempo, (*phrase.edits, edit))
|
||||
if revised:
|
||||
# Retain the refined theme, not the ornamental version, as home.
|
||||
state = dataclasses.replace(revised.state, home=phrase.state.home, themes=phrase.state.themes)
|
||||
viable.append(dataclasses.replace(revised, state=state, initial=phrase.initial, refined=phrase.refined, routes=(*phrase.routes, route)))
|
||||
else:
|
||||
context.diagnostics['complete_routes_rejected'] = context.diagnostics.get('complete_routes_rejected', 0) + 1
|
||||
# A selected approach is an explicit musical operation. Its complete valid
|
||||
# routes compete with one another; a zero-edit movement advantage must not
|
||||
# silently cancel every requested run. Failure retains the plain tune.
|
||||
eligible = viable[1:] if phrase.draft.lead_identity and len(viable) > 1 else viable
|
||||
chosen = min(eligible, key=lambda p: (p.score, len(p.edits), tuple(n.pitch for n in emitted_notes(p))))
|
||||
if chosen is not phrase:
|
||||
context.diagnostics['complete_routes_selected'] = context.diagnostics.get('complete_routes_selected', 0) + 1
|
||||
phrase = chosen
|
||||
context.diagnostics['elaboration_seconds'] = context.diagnostics.get('elaboration_seconds', 0) + time.monotonic() - started
|
||||
return phrase
|
||||
|
||||
def revise_joins(phrase, incoming, harmony, sequences, tempo, context):
|
||||
started = time.monotonic()
|
||||
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
|
||||
for _ in range(generator.solo.policy.DEFAULT.revision_sweeps):
|
||||
before = phrase
|
||||
notes = emitted_notes(phrase)
|
||||
|
||||
def weakness(join):
|
||||
before = next((n for n in reversed(notes) if n.start < join), None)
|
||||
after = next((n for n in notes if n.start >= join), None)
|
||||
return (max(0, abs(after.pitch.number() - before.pitch.number()) - 4) + float(max(0, after.start - before.end))) if before and after else 0
|
||||
|
||||
joins = sorted(phrase.draft.joins, key=lambda j: (-weakness(j), j))[:generator.solo.policy.DEFAULT.revision_joins]
|
||||
for join in joins:
|
||||
phrase = improve_window(phrase, incoming, harmony, sequences, tempo, max(phrase.plan.start, join - width), min(phrase.plan.end, join + width), 'join', context)
|
||||
if phrase == before:
|
||||
break
|
||||
context.diagnostics['revision_seconds'] = context.diagnostics.get('revision_seconds', 0) + time.monotonic() - started
|
||||
return phrase
|
||||
|
||||
def revise_pair(first, second, incoming, choices, sequences, tempo, context):
|
||||
"""Revise an uncommitted connected phrase seam, then rebuild both states."""
|
||||
if first.routes or second.routes or first.refined or second.refined or not context.revise or not first.draft or not second.draft or first.plan.ending != 'connected' or first.plan.passage_id != second.plan.passage_id:
|
||||
return first, second
|
||||
incoming = incoming if isinstance(incoming, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=incoming)
|
||||
harmony = generator.solo.harmonic.Harmony(choices)
|
||||
seam = first.plan.end
|
||||
width = fractions.Fraction(str(generator.solo.policy.DEFAULT.join_window))
|
||||
combined = (*emitted_notes(first), *emitted_notes(second))
|
||||
draft = dataclasses.replace(first.draft, end=second.plan.end, notes=(*first.draft.notes, *second.draft.notes), transformations=(*first.draft.transformations, *second.draft.transformations), structural=tuple(dict.fromkeys((*first.draft.structural, *second.draft.structural))), protected=(*first.draft.protected, *second.draft.protected), joins=(*first.draft.joins, seam, *second.draft.joins))
|
||||
joint = dataclasses.replace(first, decisions=(generator.solo.types.Decision(first.plan.start, second.plan.end, 'provisional phrase pair', 'continue', combined),), draft=draft)
|
||||
candidates = [(second.score + generator.solo.drafts.relationship_features(draft, combined)['join_coherence'], first, second)]
|
||||
left_ids = {n.event_id for n in emitted_notes(first)}
|
||||
for notes, source in window_variants(joint, seam - width, seam + width, harmony, sequences, 'join')[:generator.solo.policy.DEFAULT.join_alternatives]:
|
||||
left = tuple(n for n in notes if n.event_id in left_ids)
|
||||
right = tuple(n for n in notes if n.event_id not in left_ids)
|
||||
if any(n.end > seam for n in left) or any(n.start < seam for n in right):
|
||||
continue
|
||||
edit = generator.solo.types.DraftEdit('phrase_join', seam - width, seam + width, tuple(n.event_id for n in combined if n not in notes), tuple(n.event_id for n in notes if n not in combined), source)
|
||||
if not edit.removed:
|
||||
continue
|
||||
revised_left = checked_phrase(first.plan, first.draft, left, first.state.treatments, incoming, harmony, tempo, (*first.edits, edit))
|
||||
if not revised_left:
|
||||
continue
|
||||
revised_right = checked_phrase(second.plan, second.draft, right, second.state.treatments, revised_left.state, harmony, tempo, (*second.edits, edit))
|
||||
context.diagnostics['pair_revision_attempts'] = context.diagnostics.get('pair_revision_attempts', 0) + 1
|
||||
if revised_right:
|
||||
realized = (*emitted_notes(revised_left), *emitted_notes(revised_right))
|
||||
score = revised_right.score + generator.solo.drafts.relationship_features(draft, realized)['join_coherence']
|
||||
candidates.append((score, revised_left, revised_right))
|
||||
_, left, right = min(candidates, key=lambda item: (item[0], len(item[1].edits) + len(item[2].edits)))
|
||||
if left is not first:
|
||||
context.diagnostics['pair_revisions_accepted'] = context.diagnostics.get('pair_revisions_accepted', 0) + 1
|
||||
return left, right
|
||||
|
||||
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
|
||||
context = context if context is not None else SearchContext()
|
||||
if plan.treatment == 'lead':
|
||||
raise ValueError('generic lead identities are retired; use composer.compose for source variations')
|
||||
# Keep resources alive while identity-keyed cache entries exist. A later
|
||||
# library cannot accidentally reuse the identity of a collected object.
|
||||
context.resources[id(sequences)] = sequences
|
||||
context.resources[id(rhythms)] = rhythms
|
||||
pool_key = (plan, previous, generator.solo.policy.stable_rank('harmonic-cache', choices), tempo, id(sequences), id(rhythms), tuple(r.version for r in context.references))
|
||||
key = (*pool_key, context.revise, context.stage)
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits'] += 1
|
||||
return context.results[key]
|
||||
started = time.monotonic()
|
||||
context.diagnostics['searches'] += 1
|
||||
incoming = previous if isinstance(previous, generator.solo.types.ContinuationState) else generator.solo.types.ContinuationState('', last=previous)
|
||||
harmony = generator.solo.harmonic.Harmony(choices)
|
||||
plan = legacy_target_plan(plan, choices)
|
||||
if pool_key in context.pools:
|
||||
selected = context.pools[pool_key]
|
||||
context.diagnostics['reused_draft_pools'] = context.diagnostics.get('reused_draft_pools', 0) + 1
|
||||
else:
|
||||
constructions = generator.solo.drafts.rhythms(plan, incoming, measures, choices, rhythms, context.batch, context.references)
|
||||
complete, failure = [], ''
|
||||
for rhythm in constructions:
|
||||
candidate_plan = generator.solo.drafts.ending_plan(plan, rhythm.anchors)
|
||||
paths, error = draft_pitch_paths(rhythm, candidate_plan, choices, harmony, sequences, incoming, context)
|
||||
failure = error or failure
|
||||
context.diagnostics['generated_candidates'] += len(paths)
|
||||
for notes, cost, contracts in paths:
|
||||
family = note_family(notes)
|
||||
counts = context.diagnostics['candidate_families']
|
||||
counts[family] = counts.get(family, 0) + 1
|
||||
realizations = [notes]
|
||||
if notes and incoming.last and incoming.last.end == notes[0].start and incoming.last.pitch == notes[0].pitch and notes[0].role != 'quote' and not any(a.onset_required and a.earliest <= notes[0].start <= a.latest for a in rhythm.anchors):
|
||||
realizations.append((dataclasses.replace(notes[0], continuation=True), *notes[1:]))
|
||||
for actual in realizations:
|
||||
draft = make_draft(rhythm, actual, candidate_plan, harmony, incoming)
|
||||
phrase = checked_phrase(candidate_plan, draft, actual, contracts, incoming, harmony, tempo)
|
||||
if phrase:
|
||||
complete.append(phrase)
|
||||
if not complete:
|
||||
raise SearchExhausted(f'{plan.id}: bounded phrase draft search exhausted; {failure or "no complete draft satisfies timing, anchors, treatments and motif contracts"}; {len(constructions)} constructions / {BEAM_WIDTH} pitch states (not proof that the phrase is unplayable)')
|
||||
context.diagnostics['construction_seconds'] = context.diagnostics.get('construction_seconds', 0) + time.monotonic() - started
|
||||
selected = generator.solo.sequences.diverse(complete, lambda p: (p.score, p.draft.id, tuple(n.pitch for n in p.draft.notes)), lambda p: (p.draft.relationship, note_family(p.draft.notes), p.anchors[-1].duration if p.anchors else None), generator.solo.policy.DEFAULT.phrase_alternatives)
|
||||
context.pools[pool_key] = selected
|
||||
input_id = generator.solo.policy.stable_rank(plan.seed, (plan, previous, choices))[:16]
|
||||
pool_id = generator.solo.policy.stable_rank(plan.seed, tuple((p.draft.id, emitted_notes(p)) for p in selected))
|
||||
context.diagnostics.setdefault('draft_pool_fingerprints', {})[input_id] = pool_id
|
||||
result = []
|
||||
for index, phrase in enumerate(selected):
|
||||
if context.stage == 'initial':
|
||||
phrase = dataclasses.replace(phrase, initial=phrase.draft)
|
||||
else:
|
||||
phrase = refine(phrase, selected, incoming, harmony, sequences, tempo, context, search=index < generator.solo.policy.DEFAULT.refinement_alternatives)
|
||||
if context.stage == 'elaborated':
|
||||
phrase = elaborate(phrase, incoming, harmony, sequences, tempo, context)
|
||||
result.append(phrase)
|
||||
result = tuple(sorted(result, key=lambda p: (p.score, p.draft.id)))
|
||||
for phrase in result:
|
||||
family = note_family(emitted_notes(phrase))
|
||||
counts = context.diagnostics['surviving_families']
|
||||
counts[family] = counts.get(family, 0) + 1
|
||||
context.diagnostics.setdefault('complete_ideas', {})[input_id] = [{'id': p.draft.id, 'reference': p.draft.reference, 'score': round(p.score, 6), 'attacks': len(emitted_notes(p)), 'ending': p.plan.ending, 'hold': str(p.anchors[-1].duration) if p.anchors else '', 'edits': [e.kind for e in p.edits]} for p in result]
|
||||
context.results[key] = result
|
||||
return result
|
||||
|
||||
def compose(plan, measures, choices, sequences, rhythms, tempo, previous=None):
|
||||
return alternatives(plan, measures, choices, sequences, rhythms, tempo, previous)[0]
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Accepted, paired pitch/rhythm examples; production uses contextual profiles."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.solo.types as types
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ReferencePhrase:
|
||||
id: str
|
||||
version: str
|
||||
notes: tuple[types.Note, ...]
|
||||
decorated: tuple[types.Note, ...]
|
||||
duration: F
|
||||
tonic: int
|
||||
harmony: tuple[tuple[F, str], ...]
|
||||
features: tuple[tuple[str, F, F], ...]
|
||||
runs: tuple[tuple[F, F], ...]
|
||||
acceptance: str
|
||||
development: tuple[tuple[str, F, F, tuple[int, ...]], ...] = ()
|
||||
def windows(self):
|
||||
# Windows retain their parent and context; they are not separate approvals.
|
||||
result = []
|
||||
for length in (F(8), F(16)):
|
||||
for start in range(0, int(self.duration), int(length)):
|
||||
end = min(self.duration, start + length)
|
||||
selected = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in self.notes if start <= n.start and n.end <= end)
|
||||
if len(selected) >= 2:
|
||||
chords = tuple(chord for time, chord in self.harmony if start <= time < end)
|
||||
result.append(types.Motif(f'{self.id}:{start}:{end}', selected, end - start, chords, 'accepted reference profile'))
|
||||
return tuple(result)
|
||||
|
||||
def read(path):
|
||||
raw = path.read_bytes()
|
||||
data = json.loads(raw)
|
||||
if data['pitch_convention'] != 'sounding' or not data.get('acceptance'):
|
||||
raise ValueError(f'{path}: reference requires sounding pitches and explicit acceptance provenance')
|
||||
|
||||
def notes(variant):
|
||||
result, cursor = [], F()
|
||||
for i, event in enumerate(data['variants'][variant]['events']):
|
||||
start, duration = F(event['onset_beats']), F(event['duration_beats'])
|
||||
if start != cursor or duration <= 0:
|
||||
raise ValueError(f'{path}: reference events must fill exact sequential time')
|
||||
cursor = start + duration
|
||||
if event['pitch'] is None:
|
||||
continue
|
||||
name = event['pitch']
|
||||
pitch = types.Pitch(name[0].lower(), F(1 if '#' in name else -1 if 'b' in name else 0), int(name[-1]))
|
||||
if pitch.number() != event['midi_pitch']:
|
||||
raise ValueError(f'{path}: inconsistent sounding pitch {name}')
|
||||
result.append(types.Note(start, cursor, pitch, (data['id'],), event_id=f'{variant}_{i}'))
|
||||
return tuple(result), cursor
|
||||
|
||||
plain, duration = notes('lead_theme')
|
||||
decorated, other_duration = notes('lead_with_runs')
|
||||
if duration != other_duration or len(plain) != data['variants']['lead_theme']['attacks'] or len(decorated) != data['variants']['lead_with_runs']['attacks']:
|
||||
raise ValueError(f'{path}: inconsistent reference variants')
|
||||
tonic = generator.model.parse_pitch_name(data['tonic'])
|
||||
number = int(generator.model.NATURAL[tonic['letter']] + generator.model.alteration(tonic['alter']))
|
||||
harmony = tuple((F(4 * (h['bar'] - 1)), h['chord']) for h in data['harmony'])
|
||||
for _, chord in harmony:
|
||||
generator.harmony.parse_symbol(chord)
|
||||
return ReferencePhrase(data['id'], hashlib.sha256(raw).hexdigest(), plain, decorated, duration, number, harmony,
|
||||
tuple((f['id'], F(f['start']), F(f['end'])) for f in data['features']), tuple((F(w['start']), F(w['end'])) for w in data['run_windows']), data['acceptance'],
|
||||
tuple((d['role'], F(d['start']), F(d['end']), tuple(d.get('degrees', ()))) for d in data.get('development', ())))
|
||||
|
||||
def load_library(root: pathlib.Path):
|
||||
return tuple(read(path) for path in sorted(root.glob('*.json')))
|
||||
|
||||
from generator.solo.features import identifying_features, feature_error, relationship
|
||||
+37
-176
@@ -1,185 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import fractions
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.scales.report
|
||||
import generator.scales.selection
|
||||
import generator.scales.timeline
|
||||
import generator.solo.types
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.drafts
|
||||
import generator.solo.similarity
|
||||
import generator.solo.features
|
||||
"""Compact reports of actual selected phrases, not duplicated draft ancestry."""
|
||||
import dataclasses, fractions, json
|
||||
import generator.solo.placement
|
||||
from generator.solo import harmonic
|
||||
|
||||
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0, *, detail=None) -> str:
|
||||
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
|
||||
positioned = tuple(dataclasses.replace(c, span=dataclasses.replace(c.span, start=c.span.start + time_offset, end=c.span.end + time_offset, measure=c.span.measure + measure_offset)) for c in solo.choices)
|
||||
lines = [generator.scales.report.passage(solo.part, region, positioned, scales), f'Phrase composition: {solo.part}', f'Placement reference tempo: {solo.tempo} quarter notes/minute. Runtime tempo changes are not a comfort guarantee.', 'Times below are quarter-note offsets from the start of this solo; pitches are sounding.']
|
||||
if solo.diagnostics:
|
||||
lines.append('Composition diagnostics: ' + json.dumps({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}, sort_keys=True,default=str))
|
||||
detail = structure(solo) if detail is None else detail
|
||||
lines.append("Sustained phrasing: " + json.dumps(detail["phrasing"], sort_keys=True))
|
||||
for passage in detail['passages']:
|
||||
lines.append(f"Melodic passage {passage['id']}: {passage['start']}-{passage['end']}; phrases {', '.join(passage['phrases'])}")
|
||||
lines.append(f"Harmonic exposure: {detail['harmonic_exposure']:.4f}; unresolved treatments: {len(detail['unresolved_treatments'])}")
|
||||
for phrase in solo.phrases:
|
||||
plan = phrase.plan
|
||||
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
|
||||
lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}')
|
||||
if plan.treatment in ('source_variation', 'thematic_recomposition'):
|
||||
lines.append(' Activity follows the selected gesture and tempo-aware vocabulary; occupancy is diagnostic, not a target.')
|
||||
if phrase.draft:
|
||||
draft = phrase.draft
|
||||
lines.append(f' Complete draft {draft.id}: {draft.relationship}; home {draft.home.id if draft.home else "none"}; {len(draft.notes)} attacks; rests {draft.rests()}')
|
||||
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 enclosure in draft.enclosures:
|
||||
lines.append(f' Enclosure {enclosure.id}: {enclosure.upper.pitch.label()} → {enclosure.lower.pitch.label()} → {enclosure.target.pitch.label()}; {enclosure.target_role} over {enclosure.chord}; arrival {enclosure.target.start}; protected through {enclosure.target.end}')
|
||||
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}')
|
||||
for transform in draft.transformations:
|
||||
lines.append(f' Development {transform.id}: {transform.operation}; {transform.start}-{transform.end}; parent {transform.parent}; home {transform.home}; preserves {transform.preserves}')
|
||||
for edit in phrase.edits:
|
||||
lines.append(f' Revision {edit.kind}: {edit.start}-{edit.end}; {edit.source}; replaced {edit.removed}; realized {edit.added}')
|
||||
for passage in plan.passages:
|
||||
lines.append(f' Activity {passage.start}-{passage.end}: {passage.intent}; attacks/beat {float(passage.activity):.3f}; occupancy {float(passage.occupancy):.2f}')
|
||||
for landmark in plan.landmarks:
|
||||
lines.append(f' Landmark {landmark.name}: {landmark.earliest}-{landmark.latest}; sounding MIDI {landmark.low}-{landmark.high}; direction {landmark.direction}')
|
||||
if plan.target:
|
||||
lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}')
|
||||
for anchor in phrase.anchors:
|
||||
actual = generator.solo.harmonic.anchor_note(anchor, phrase.state.notes if phrase.state else solo.notes())
|
||||
lines.append(f' Anchor {anchor.id}: {anchor.role}/{anchor.effect}; {anchor.chord}; {anchor.pitch.label()}; window {anchor.earliest}-{anchor.latest}; hold {anchor.duration}; hold until {anchor.hold_until}; completed {actual is not None}')
|
||||
if phrase.state:
|
||||
valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=phrase.plan.end == phrase.plan.passage_end, closure=phrase.plan.closure)
|
||||
lines.append(f' Ending contract fulfilled: {valid}; {reason}')
|
||||
for motif in phrase.state.motifs:
|
||||
if phrase.plan.start <= motif.source_start < phrase.plan.end:
|
||||
lines.append(f' Realized motif {motif.id}: parent {motif.parent}; {motif.relationship}; {motif.treatment}; pitches ' + ' '.join(n.pitch.label() for n in motif.notes) + f'; rhythm ' + ' '.join(f'{n.start}:{n.end}' for n in motif.notes))
|
||||
for decision in phrase.decisions:
|
||||
lines.append(f' {decision.start}-{decision.end}: {decision.source}; {decision.treatment}')
|
||||
if not decision.notes:
|
||||
lines.append(' Silence')
|
||||
for note in decision.notes:
|
||||
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)
|
||||
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 'in-collection connection'
|
||||
if note.pitch.number()%12 not in generator.solo.types.permitted(solo.choices,note.start,note.end):
|
||||
function='validated expression exception'
|
||||
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}')
|
||||
for expression in detail['expressions']:
|
||||
lines.append('Expression: ' + expression['kind'] + ' / ' + expression['rule'] + ' -> ' + str(expression['target']['pitch']))
|
||||
lines.append('\nSounding harmonic spans (ties merged before classification):')
|
||||
for event in detail['harmonic_roles']:
|
||||
if float(fractions.Fraction(event['sounding_duration'])) >= 1 or event['protected'] or event['enclosure']:
|
||||
lines.append(' ' + json.dumps(event, sort_keys=True))
|
||||
return '\n'.join(lines) + '\n'
|
||||
def plain(value):
|
||||
if dataclasses.is_dataclass(value):return plain(dataclasses.asdict(value))
|
||||
if isinstance(value,dict):return {k:plain(v) for k,v in value.items()}
|
||||
if isinstance(value,(tuple,list,set,frozenset)):return [plain(v) for v in value]
|
||||
if isinstance(value,fractions.Fraction):return str(value)
|
||||
return value
|
||||
|
||||
def structure(solo):
|
||||
harmony = generator.solo.harmonic.Harmony(solo.choices)
|
||||
contracts = tuple(dict.fromkeys(t for p in solo.phrases if p.state for t in p.state.treatments))
|
||||
enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures)
|
||||
expressions = tuple(e for p in solo.phrases if p.draft for e in p.draft.expressions)
|
||||
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures,expressions=expressions)
|
||||
|
||||
def rational(value):
|
||||
if isinstance(value, dict):
|
||||
return {key: rational(item) for key, item in value.items()}
|
||||
if isinstance(value, (tuple, list, set, frozenset)):
|
||||
return [rational(item) for item in value]
|
||||
if isinstance(value, fractions.Fraction):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
phrases = []
|
||||
for phrase in solo.phrases:
|
||||
notes = phrase.state.notes if phrase.state else solo.notes()
|
||||
complete = phrase.plan.end == (phrase.plan.passage_end or phrase.plan.end)
|
||||
valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, notes, phrase.plan.end, complete=complete, closure=phrase.plan.closure)
|
||||
anchors = []
|
||||
for anchor in phrase.anchors:
|
||||
event = generator.solo.harmonic.anchor_note(anchor, notes)
|
||||
anchors.append({**rational(dataclasses.asdict(anchor)), 'pitch': anchor.pitch.label(), 'degrees': next((list(degrees) for pc,degrees in harmony.at(anchor.earliest).roles.degrees if pc==anchor.pitch.number()%12),[]), 'completed': event is not None, 'actual_start': str(event.start) if event else None, 'actual_end': str(event.end) if event else None})
|
||||
motifs = tuple(m for m in phrase.state.motifs if phrase.plan.start <= m.source_start < phrase.plan.end) if phrase.state else ()
|
||||
entry = {'id': phrase.plan.id, 'passage': phrase.plan.passage_id, 'start': str(phrase.plan.start), 'end': str(phrase.plan.end), 'ending': phrase.plan.ending, 'closure': phrase.plan.closure, 'ending_completed': valid, 'ending_error': reason, 'anchors': anchors, 'motif_lineage': rational([dataclasses.asdict(m) for m in motifs])}
|
||||
if phrase.draft:
|
||||
entry.update({'draft': rational(dataclasses.asdict(phrase.draft)), 'draft_rests': rational(phrase.draft.rests()), 'edits': rational([dataclasses.asdict(e) for e in phrase.edits]), 'relationships': generator.solo.drafts.relationship_features(phrase.draft, tuple(n for d in phrase.decisions for n in d.notes)), 'home': phrase.state.home.id if phrase.state and phrase.state.home else None, 'current_development': phrase.state.development.id if phrase.state and phrase.state.development else None})
|
||||
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), ('before_enclosure',phrase.before_enclosure)) if draft}
|
||||
entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes])
|
||||
if phrase.initial and phrase.refined:
|
||||
# A source hold may already be sounding at this generated phrase boundary.
|
||||
# Detach its clipped entrance for the local comparison only; the full
|
||||
# source comparison below still merges its original tied continuations.
|
||||
local_source=generator.solo.drafts.local_sounding(phrase.initial.notes)
|
||||
entry['head_comparison_plain']=generator.solo.similarity.compare(phrase.refined.notes,local_source)
|
||||
entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),local_source)
|
||||
if phrase.state and phrase.state.home:
|
||||
entry['theme_relationship']=generator.solo.features.relationship(phrase.refined.notes,phrase.state.home)
|
||||
entry['evaluation'] = rational(dataclasses.asdict(phrase.evaluation)) if phrase.evaluation else None
|
||||
from generator.solo import groups
|
||||
try:
|
||||
entry['rhythm_groups']=rational([dataclasses.asdict(g) for g in groups.describe(tuple((n.start-phrase.plan.start,n.end-phrase.plan.start) for n in phrase.draft.notes),origin=phrase.plan.start)]) if phrase.draft else []
|
||||
except ValueError as error:
|
||||
entry['rhythm_groups']=[{'family':'source','reason':str(error)}]
|
||||
entry['score'] = phrase.score
|
||||
harmony=harmonic.Harmony(solo.choices)
|
||||
contracts=tuple(c for p in solo.phrases for c in p.expressions)
|
||||
checked=harmony.evaluate(solo.notes(),until=solo.end,complete=True,expressions=contracts)
|
||||
phrases=[];endings=[]
|
||||
for p in solo.phrases:
|
||||
anchor=p.destination
|
||||
completed=anchor is None or harmonic.anchor_note(anchor,p.notes) is not None
|
||||
entry={'id':p.id,'start':str(p.start),'end':str(p.end),'purpose':p.purpose,'direction':p.direction,'ending':p.ending,
|
||||
'theme':p.theme,'notes':plain(p.notes),'rhythm_groups':plain(p.groups),'sources':p.sources,
|
||||
'anchors':[{**plain(anchor),'pitch':anchor.pitch.label(),'completed':completed}] if anchor else [],
|
||||
'ending_completed':completed,'evaluation':plain(p.evaluation),'score':p.score,'expressions':plain(p.expressions)}
|
||||
phrases.append(entry)
|
||||
return {'schema':'composer-report-2','selection_trace':rational({k:v for k,v in solo.diagnostics.items() if not k.endswith('_seconds')}), 'instrument_profile': rational(dataclasses.asdict(solo.profile)) if solo.profile else None, 'phrasing': phrasing_report(solo), '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, 'expressions': rational([dataclasses.asdict(e) for e in expressions]), 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures,expressions), '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 phrasing_report(solo):
|
||||
import collections
|
||||
from generator.solo import phrasing
|
||||
notes=solo.notes()
|
||||
figures=[phrasing.heard(notes,'chorus')] if notes else []
|
||||
exact=collections.Counter();relative=collections.Counter()
|
||||
for figure in figures:
|
||||
# Count occurrences, not just unique vocabulary within each phrase.
|
||||
for i in range(len(figure.events)-3):
|
||||
group=figure.events[i:i+4];start,_,pitch=group[0]
|
||||
exact[tuple((a-start,d,p) for a,d,p in group)]+=1
|
||||
relative[tuple((a-start,d,p-pitch) for a,d,p in group)]+=1
|
||||
endings=[];regions=[];appearances=[]
|
||||
for phrase in solo.phrases:
|
||||
local=tuple(n for d in phrase.decisions for n in d.notes)
|
||||
for anchor in phrase.anchors:
|
||||
if anchor.role!='terminal' or phrase.plan.closure!='closed':continue
|
||||
note=generator.solo.harmonic.anchor_note(anchor,phrase.state.notes)
|
||||
endings.append({'phrase':phrase.plan.id,'arrival':str(note.start) if note else None,'pitch':anchor.pitch.label(),
|
||||
'effect':anchor.effect,'required_beats':str(anchor.duration),'sounding_beats':str(note.end-note.start) if note else None,
|
||||
'release':str(phrase.plan.release),'completed':note is not None})
|
||||
if local:
|
||||
regions.append({'start':str(phrase.plan.start),'end':str(phrase.plan.end),
|
||||
'low':min(n.pitch.number() for n in local),'high':max(n.pitch.number() for n in local),
|
||||
'duration_weighted_mean':sum(n.pitch.number()*float(n.end-n.start) for n in local)/sum(float(n.end-n.start) for n in local)})
|
||||
if phrase.draft:
|
||||
for transform,feature in zip(phrase.draft.transformations,phrase.draft.features):
|
||||
appearances.append({'start':str(transform.start),'operation':transform.operation,'home':transform.home,
|
||||
'preserves':list(feature.preserves),'feature_source':feature.source})
|
||||
activity={}
|
||||
for intent in ('active','sustained_arrival','space'):
|
||||
spans=[s for p in solo.phrases for s in p.plan.passages if s.intent==intent]
|
||||
duration=sum(float(s.end-s.start) for s in spans)
|
||||
attacks=sum(any(s.start<=n.start<s.end for s in spans) for n in notes)
|
||||
occupancy=sum(float(max(0,min(n.end,s.end)-max(n.start,s.start))) for n in notes for s in spans)
|
||||
activity[intent]={'beats':duration,'attacks':attacks,'attacks_per_four_beats':4*attacks/duration if duration else 0,
|
||||
'occupancy':occupancy/duration if duration else 0}
|
||||
return {'completed_sustained_endings':sum(e['completed'] and fractions.Fraction(e['sounding_beats'])>=2 for e in endings),
|
||||
'endings':endings,'appearances':appearances,'clear_returns':sum(a['operation']=='return' for a in appearances),
|
||||
'exact_four_note_recurrences':sum(max(0,n-1) for n in exact.values()),
|
||||
'interval_rhythm_four_note_recurrences':sum(max(0,n-1) for n in relative.values()),
|
||||
'activity':activity,'register_regions':regions}
|
||||
if anchor:endings.append({'phrase':p.id,'effect':p.ending,'arrival':str(anchor.earliest),'pitch':anchor.pitch.label(),'required_beats':str(anchor.duration),'completed':completed})
|
||||
ns=solo.notes()
|
||||
return {'schema':'composer-report-3','selection_trace':plain({k:v for k,v in solo.diagnostics.items() if not k.endswith('_seconds')}),
|
||||
'instrument_profile':plain(solo.profile),'phrases':phrases,'expressions':plain(contracts),'fretboard':fretboard_report(solo),
|
||||
'phrasing':{'endings':endings,'attacks':len(ns),'attacks_per_four_beats':4*len(ns)/float(solo.end) if solo.end else 0,
|
||||
'occupancy':sum(float(n.end-n.start) for n in ns)/float(solo.end) if solo.end else 0},
|
||||
'harmonic_roles':harmony.describe(ns,expressions=contracts),'harmonic_valid':checked.valid,'harmonic_error':checked.error,
|
||||
'harmonic_exposure':checked.exposure,'unresolved_treatments':plain(checked.pending)}
|
||||
|
||||
def describe(solo,scales,measure_offset=0,time_offset=0,*,detail=None):
|
||||
detail=detail or structure(solo)
|
||||
lines=[f'Independent complete-phrase solo: {solo.part}', 'Times are exact quarter beats from the solo start; pitches sound one octave below guitar notation.',
|
||||
'Approximate fingering costs are not a biomechanical guarantee.',json.dumps(detail['phrasing'])]
|
||||
for p in solo.phrases:
|
||||
lines.append(f'{p.id}: {p.start}–{p.end}; {p.purpose}, {p.direction}, {p.ending}; theme {p.theme}; score {p.score:.4f}')
|
||||
for n in p.notes:lines.append(f' {n.start}–{n.end}: {n.pitch.label()}, {n.role}; string {n.position.string_number}, fret {n.position.fret}')
|
||||
for c in p.expressions:lines.append(f' {c.kind}/{c.rule} → {c.target.pitch.label()} at {c.target.start}, held to {c.target.end}')
|
||||
return '\n'.join(lines)+'\n'
|
||||
|
||||
def fretboard_report(solo):
|
||||
import collections
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Complete generated rhythm groups, indexed once, independent of source rhythms."""
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary
|
||||
from generator.solo import groups, policy
|
||||
|
||||
class Rhythms:
|
||||
def __init__(self, library, tempo):
|
||||
self.groups=[]; limits=vocabulary.data('roles')['difficulty']
|
||||
for item in vocabulary.data('solo')['groups']:
|
||||
difficulty=item.get('difficulty')
|
||||
if difficulty and tempo>limits[difficulty+'_max_bpm']:continue
|
||||
cursor=F();pattern=[]
|
||||
for value in item['values']:
|
||||
duration=F(value.removeprefix('r:'))
|
||||
if not value.startswith('r:'):pattern.append((cursor,cursor+duration))
|
||||
cursor+=duration
|
||||
self.groups.append((item['id'],tuple(pattern),cursor,tuple(item['purpose'])))
|
||||
for cell in vocabulary.rhythm_cells(tempo):
|
||||
# Imported authored cells remain whole. They are never sliced to fit.
|
||||
if groups.valid(cell.pattern):self.groups.append((cell.id,cell.pattern,cell.duration,('develop','answer') if cell.purpose=='answer' else ('arpeggio','scalar','develop','neighbor')))
|
||||
# Meter-indexed catalog representatives: file counts do not set style frequency.
|
||||
self.catalog={}
|
||||
for meter,items in library.snippets.items():
|
||||
representatives={}
|
||||
for item in items:
|
||||
for bar in item.bars:
|
||||
pattern=tuple((e.onset,e.onset+e.duration) for e in bar.events if e.sounding)
|
||||
if any(e.continuation for e in bar.events) or not pattern or not groups.valid(pattern):continue
|
||||
if any(b-a<F(1,2) for a,b in pattern):continue
|
||||
descriptor=(len(pattern),pattern[0][0]>0,any(a[1]<b[0] for a,b in zip(pattern,pattern[1:])),len(set(b-a for a,b in pattern)))
|
||||
representatives.setdefault(descriptor,(item.id,pattern,bar.length,('develop','answer')))
|
||||
self.catalog[meter]=tuple(representatives.values())
|
||||
self.cache={}
|
||||
|
||||
def realize(self,start,end,purpose,seed,meter):
|
||||
key=(start,end,purpose,seed,meter)
|
||||
if key in self.cache:return self.cache[key]
|
||||
cursor=start;notes=[];sources=[];chosen=[];home=None;uses=0
|
||||
while cursor<end:
|
||||
available=[g for g in self.groups if purpose in g[3] and g[2]<=end-cursor]
|
||||
# Catalog is a separate family, not thousands of duplicate votes.
|
||||
if purpose in ('develop','answer'):
|
||||
available += sorted((g for g in self.catalog.get(meter,()) if g[2]<=end-cursor),key=lambda g:policy.stable_rank(seed,('catalog',cursor,g[0])))[:1]
|
||||
available=[g for g in available if ((end-cursor-g[2])*2).denominator==1]
|
||||
if not available:return (),(),()
|
||||
# Prefer complete multi-event groups; single values finish an exact remainder.
|
||||
large=[g for g in available if g[2]>=1]
|
||||
if home is not None and uses<2 and home in available:
|
||||
selected=home
|
||||
else:
|
||||
# Establish one rhythmic idea before reshaping it. A complete phrase
|
||||
# is not a fresh unrelated catalog draw at every short boundary.
|
||||
selected=min(large or available,key=lambda g:policy.stable_rank(seed,('rhythm',len(chosen)//4,g[0])))
|
||||
home=selected;uses=0
|
||||
uses+=1
|
||||
name,pattern,length,_=selected
|
||||
events=tuple((cursor+a,cursor+b) for a,b in pattern)
|
||||
notes.extend(events);sources.append(name)
|
||||
chosen.extend(groups.describe(pattern,origin=cursor,source=name))
|
||||
cursor+=length
|
||||
result=(tuple(notes),tuple(chosen),tuple(sources));self.cache[key]=result
|
||||
return result
|
||||
+95
-267
@@ -1,271 +1,99 @@
|
||||
"""Instantiate complete authored routes; a failed path is never repaired note by note."""
|
||||
from __future__ import annotations
|
||||
|
||||
"""Complete pitch paths for joint phrase proposals, with explicit structural motion."""
|
||||
import dataclasses
|
||||
import fractions
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary
|
||||
from generator.solo import types, policy, sequences
|
||||
|
||||
import generator.fretboard
|
||||
import generator.solo.placement
|
||||
import generator.solo.policy
|
||||
import generator.solo.sequences
|
||||
import generator.solo.types as types
|
||||
import generator.vocabulary
|
||||
class Routes:
|
||||
def __init__(self,harmony,library):
|
||||
self.harmony=harmony;self.library=library;self.pitch_cache={};self.cache={}
|
||||
# Every short relative path is reachable by attack count and exact displacement.
|
||||
self.index={}
|
||||
self.steps=frozenset(s.steps[0] for s in library.paths.get(2,()))
|
||||
for values in library.paths.values():
|
||||
for value in values:self.index.setdefault((len(value.steps)+1,value.offsets()[-1]),[]).append(value)
|
||||
|
||||
def pitches(self,start,end):
|
||||
key=(start,end)
|
||||
if key not in self.pitch_cache:
|
||||
span=self.harmony.at(start)
|
||||
allowed=types.permitted(self.harmony.choices,start,end)
|
||||
if end-start>=2:allowed &= self.harmony.members(start,end)| (span.roles.specified if span else frozenset())
|
||||
self.pitch_cache[key]=tuple(types.scale_pitch(span.scale,i) for i in range(-28,29)
|
||||
if types.scale_pitch(span.scale,i).number()%12 in allowed and self.harmony.profile.permits(types.scale_pitch(span.scale,i).number())) if span and span.scale else ()
|
||||
return self.pitch_cache[key]
|
||||
|
||||
def path_cost(notes):
|
||||
"""Penalize unexplained extremes, not every third or arpeggio interval."""
|
||||
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(notes,notes[1:])]
|
||||
excess=sum(max(0,abs(v)-7) for v in intervals)/max(1,len(intervals))
|
||||
oscillation=sum(a!=0 and a==-b and a==c and b==d for a,b,c,d in zip(intervals,intervals[1:],intervals[2:],intervals[3:]))
|
||||
return excess+.1*oscillation
|
||||
|
||||
|
||||
def path_family(notes,harmony):
|
||||
intervals=tuple(b.pitch.number()-a.pitch.number() for a,b in zip(notes,notes[1:]))
|
||||
chordal=all(n.pitch.number()%12 in harmony.members(n.start,n.end) for n in notes)
|
||||
return ('arpeggio' if chordal and any(abs(v)>=3 for v in intervals) else 'mapped',
|
||||
generator.solo.sequences.family(intervals))
|
||||
|
||||
def advance(scale, previous, step):
|
||||
try:
|
||||
degree = types.scale_index(scale, previous)
|
||||
return tuple([types.scale_pitch(scale, degree + step)])
|
||||
except ValueError:
|
||||
# At a changed collection count actual intervening scale tones from the
|
||||
# incoming sound. No old degree number is transferred to the new scale.
|
||||
if not step:
|
||||
return ()
|
||||
nearby = sorted((types.scale_pitch(scale, i) for i in range(-28, 35)), key=lambda p: p.number())
|
||||
outgoing = [p for p in nearby if (p.number() - previous) * step > 0]
|
||||
if step < 0:
|
||||
outgoing.reverse()
|
||||
return tuple(outgoing[abs(step) - 1:abs(step)])
|
||||
|
||||
def candidates(notes, fixed, harmony, library, register, relationship='connect theme', *, purpose='connect', expressions=()):
|
||||
if expressions:
|
||||
return planned_candidates(notes,fixed,harmony,register,relationship,expressions)
|
||||
# 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.concrete_routes={}
|
||||
harmony.route_libraries[id(library)]=library
|
||||
concrete_key=(notes,frozenset(fixed),id(library),register,harmony.profile,purpose)
|
||||
if concrete_key in harmony.concrete_routes:
|
||||
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.concrete_routes[concrete_key])
|
||||
# Event/source IDs and non-fixed placeholder pitches do not affect route
|
||||
# feasibility. Cache the pitch work across different thematic histories,
|
||||
# then restore this caller's provenance before ranking complete routes.
|
||||
fixed_indices=frozenset(i for i,n in enumerate(notes) if n.event_id in fixed)
|
||||
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i),role='connection',position=None,
|
||||
pitch=n.pitch if i==0 or i==len(notes)-1 or i in fixed_indices else types.Pitch('c',fractions.Fraction(),0)) for i,n in enumerate(notes))
|
||||
key=(canonical,fixed_indices,id(library),register,harmony.profile,purpose)
|
||||
if key not in harmony.route_cache:
|
||||
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register,purpose)
|
||||
result=[]
|
||||
for route in harmony.route_cache[key]:
|
||||
actual=tuple(n if i==0 else dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==r.pitch.number() else r.pitch,position=None)
|
||||
for i,(n,r) in enumerate(zip(notes,route.notes)))
|
||||
identity=generator.solo.policy.stable_rank('complete-route',(actual,route.sources))[:16]
|
||||
result.append(dataclasses.replace(route,id=identity,notes=actual,destination=notes[-1],relationship=relationship))
|
||||
selected=generator.solo.sequences.diverse(result,
|
||||
lambda r:(path_cost(r.notes),r.id),
|
||||
lambda r:path_family(r.notes,harmony),
|
||||
generator.solo.policy.DEFAULT.join_alternatives)
|
||||
if len(harmony.concrete_routes)>=8192:harmony.concrete_routes.pop(next(iter(harmony.concrete_routes)))
|
||||
if len(harmony.route_cache)>=8192:harmony.route_cache.pop(next(iter(harmony.route_cache)))
|
||||
harmony.concrete_routes[concrete_key]=selected
|
||||
return selected
|
||||
|
||||
|
||||
def planned_candidates(notes,fixed,harmony,register,relationship,contracts):
|
||||
# Cache complete contract-conditioned pitch work, not a next-note suggestion.
|
||||
# IDs/provenance are restored below; timing, pitch, role, obligations and
|
||||
# profile remain in the key. The cache is owned by this harmonic timeline.
|
||||
ids={n.event_id:str(i) for i,n in enumerate(notes)}
|
||||
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i)) for i,n in enumerate(notes))
|
||||
def note(n):return canonical[int(ids[n.event_id])]
|
||||
obligations=tuple(dataclasses.replace(c,id=str(i),events=tuple(map(note,c.events)),
|
||||
target=note(c.target),preparation=note(c.preparation) if c.preparation else None) for i,c in enumerate(contracts))
|
||||
locked=frozenset(ids[i] for i in fixed)
|
||||
key=(canonical,locked,register,harmony.profile,obligations)
|
||||
if not hasattr(harmony,'planned_route_cache'):harmony.planned_route_cache={}
|
||||
if key not in harmony.planned_route_cache:
|
||||
harmony.planned_route_cache[key]=_planned_candidates(canonical,locked,harmony,register,'',obligations)
|
||||
if len(harmony.planned_route_cache)>8192:harmony.planned_route_cache.pop(next(iter(harmony.planned_route_cache)))
|
||||
result=[]
|
||||
for route in harmony.planned_route_cache[key]:
|
||||
actual=tuple(dataclasses.replace(original,pitch=realized.pitch,position=realized.position) for original,realized in zip(notes,route.notes))
|
||||
result.append(dataclasses.replace(route,notes=actual,destination=actual[-1],relationship=relationship))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _planned_candidates(notes,fixed,harmony,register,relationship,contracts):
|
||||
"""Solve the whole route around explicit, preselected harmonic obligations.
|
||||
|
||||
Fixed expressive pitches have narrow permissions; all other attacks use the
|
||||
map. Complete contracts are checked after realization, never inferred merely
|
||||
from a label. No incomplete route is handed back to the next cell.
|
||||
"""
|
||||
from generator.solo import gestures
|
||||
proposal=gestures.Proposal(notes,contracts,relationship)
|
||||
locked={n.event_id for c in contracts for n in (*c.events,c.target)}|set(fixed)|{notes[0].event_id,notes[-1].event_id}
|
||||
special={n.event_id for c in contracts for n in c.events}
|
||||
states=[(0.,(notes[0],))]
|
||||
for i,n in enumerate(notes[1:],1):
|
||||
span=harmony.at(n.start)
|
||||
if not span or not span.scale:return ()
|
||||
allowed=types.permitted(harmony.choices,n.start,n.end)
|
||||
if n.end-n.start>=2 and n.event_id not in special:allowed &= harmony.members(n.start,n.end)
|
||||
target=next((a for a in notes[i:] if a.event_id in locked),notes[-1])
|
||||
remaining=max(1,sum(a.start<target.start for a in notes[i:]))
|
||||
following=[]
|
||||
for cost,path in states:
|
||||
prior=path[-1]
|
||||
if n.event_id in locked:options=(n.pitch,)
|
||||
else:
|
||||
options=tuple(p for step in (-3,-2,-1,1,2,3) for p in advance(span.scale,prior.pitch.number(),step))
|
||||
if prior.pitch.number()%12 in allowed:options=(*options,prior.pitch)
|
||||
for pitch in options:
|
||||
number=pitch.number()
|
||||
if not harmony.profile.permits(number) or not register[0]<=number<=register[1]:continue
|
||||
if n.event_id not in special and number%12 not in allowed:continue
|
||||
motion=number-prior.pitch.number()
|
||||
if abs(motion)>12:continue
|
||||
# Continuation aims at the known future destination. Selected thirds
|
||||
# and fourths remain feasible; repeated oscillation is not progress.
|
||||
distance=abs(target.pitch.number()-number)
|
||||
remaining_cost=max(0,distance-5*remaining)/12
|
||||
repeating=len(path)>1 and path[-2].pitch==prior.pitch==pitch
|
||||
value=cost+max(0,abs(motion)-7)/12+.2*repeating
|
||||
following.append((value,(*path,dataclasses.replace(n,pitch=pitch,position=None)),remaining_cost))
|
||||
chosen=generator.solo.sequences.diverse(following,lambda v:(v[0]+v[2],tuple(n.pitch.number() for n in v[1])),
|
||||
lambda v:(generator.solo.sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(v[1],v[1][1:]))),v[1][-1].pitch.number()),8)
|
||||
states=[(cost,path) for cost,path,_ in chosen]
|
||||
if not states:return ()
|
||||
result=[]
|
||||
for _,actual in states:
|
||||
bound=proposal.bind(actual)
|
||||
if not harmony.evaluate(actual,until=actual[-1].end,complete=True,expressions=bound).valid:continue
|
||||
identity=generator.solo.policy.stable_rank('planned-route',tuple((n.start,n.end,n.pitch) for n in actual))[:16]
|
||||
result.append(types.RouteCandidate(identity,actual,actual[-1],(),relationship,tuple('gesture:'+c.rule for c in bound)))
|
||||
return tuple(result)
|
||||
|
||||
def _candidates(notes, fixed, harmony, library, register, purpose='connect'):
|
||||
"""Every returned candidate includes its entry, complete rhythm and destination.
|
||||
|
||||
Callers validate the whole surrounding sounding context and guitar path.
|
||||
This function applies exact scale paths, fixed events and range constraints.
|
||||
"""
|
||||
if not 2 <= len(notes) <= 16:
|
||||
return ()
|
||||
first, destination = notes[0], notes[-1]
|
||||
span = harmony.at(first.start)
|
||||
if not span or not span.scale:
|
||||
return ()
|
||||
contexts=tuple((harmony.at(n.start).scale if harmony.at(n.start) else None,
|
||||
types.permitted(harmony.choices,n.start,n.end) & (harmony.members(n.start,n.end) if n.end-n.start>=2 else frozenset(range(12))),
|
||||
n.pitch.number() if n.event_id in fixed or i==len(notes)-1 else None) for i,n in enumerate(notes))
|
||||
if not hasattr(harmony,'pitch_routes'):harmony.pitch_routes={}
|
||||
key=(first.pitch,contexts,id(library),register,harmony.profile)
|
||||
if key not in harmony.pitch_routes:
|
||||
harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register,harmony.profile)
|
||||
if len(harmony.pitch_routes)>8192:harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
|
||||
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)
|
||||
result=[]
|
||||
paths=list(harmony.pitch_routes[key])
|
||||
# These are traversal operators, not stored phrases. An arpeggio prefix
|
||||
# adapts to the chord at each note and a complete scale route then connects
|
||||
# it to this caller's actual destination. No endpoint is attacked twice.
|
||||
for prefix,rule in chord_prefixes(notes,fixed,harmony,register,purpose):
|
||||
i=len(prefix)-1
|
||||
if i==len(notes)-1:
|
||||
if prefix[-1].pitch.number()==destination.pitch.number():
|
||||
paths.append((tuple(n.pitch for n in prefix),('chord-path:'+rule,)))
|
||||
continue
|
||||
suffix_key=(prefix[-1].pitch,contexts[i:],id(library),register,harmony.profile)
|
||||
if suffix_key not in harmony.pitch_routes:
|
||||
while len(harmony.pitch_routes)>=8192:
|
||||
harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
|
||||
harmony.pitch_routes[suffix_key]=pitch_paths(prefix[-1].pitch,contexts[i:],library,register,harmony.profile)
|
||||
for tail,sources in harmony.pitch_routes[suffix_key][:2]:
|
||||
paths.append((tuple(n.pitch for n in prefix[:-1])+tail,('chord-path:'+rule,*sources)))
|
||||
for pitches,sources in paths:
|
||||
realized=(first,*(dataclasses.replace(n,pitch=n.pitch if n.pitch.number()==p.number() else p,position=None)
|
||||
for n,p in zip(notes[1:],pitches[1:])))
|
||||
identity=generator.solo.policy.stable_rank('complete-route',(realized,sources))[:16]
|
||||
result.append(types.RouteCandidate(identity,realized,destination,route_spans,'connect theme',sources))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def chord_prefixes(notes,fixed,harmony,register,purpose):
|
||||
from generator.solo import phrasing
|
||||
if len(notes)<2:return ()
|
||||
values=[]
|
||||
first=notes[0];destination=notes[-1]
|
||||
for rule in generator.vocabulary.data('melodic_vocabulary')['chord_paths']:
|
||||
if purpose not in rule['purpose']:continue
|
||||
if rule['require_chord_entry'] and first.pitch.number()%12 not in harmony.members(first.start,first.end):continue
|
||||
displacement=destination.pitch.number()-first.pitch.number()
|
||||
direction=1 if rule['direction']=='up' else -1 if rule['direction']=='down' else (1 if displacement>=0 else -1)
|
||||
for count in rule['prefix_attacks']:
|
||||
if count>len(notes):continue
|
||||
prefix=[first]
|
||||
for i,n in enumerate(notes[1:count]):
|
||||
span=harmony.at(n.start)
|
||||
if not span or not span.scale:break
|
||||
eligible=harmony.members(n.start,n.end)&types.permitted(harmony.choices,n.start,n.end)
|
||||
if rule['members']=='defining':eligible=eligible&phrasing.defining_members(span.chord)
|
||||
step=rule['member_steps'][i%len(rule['member_steps'])]
|
||||
old=prefix[-1].pitch.number()
|
||||
pitches=sorted((types.scale_pitch(span.scale,j) for j in range(-21,29)),key=lambda p:p.number(),reverse=direction*step<0)
|
||||
options=[p for p in pitches if p.number()%12 in eligible and 0<(p.number()-old)*direction*step
|
||||
and abs(p.number()-old)<=rule['max_interval'] and register[0]<=p.number()<=register[1] and harmony.profile.permits(p.number())]
|
||||
if len(options)<abs(step):break
|
||||
selected=options[abs(step)-1]
|
||||
if (n.event_id in fixed or i+1==len(notes)-1) and selected.number()!=n.pitch.number():break
|
||||
prefix.append(dataclasses.replace(n,pitch=selected))
|
||||
if len(prefix)==count:values.append((tuple(prefix),rule['id']))
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def pitch_paths(first, contexts, library, register, profile=None):
|
||||
"""Time-independent pitch work; context includes every overlapped collection.
|
||||
|
||||
Sounding durations and chord roles are still validated on each concrete
|
||||
realization. Equal collection contexts can reuse paths across rhythmic edits.
|
||||
"""
|
||||
from generator.solo.profiles import UNRESTRICTED
|
||||
profile = profile or UNRESTRICTED
|
||||
if not profile.permits(first.number()): return ()
|
||||
destination=contexts[-1][2]
|
||||
try:
|
||||
displacement=types.scale_index(contexts[0][0],destination)-types.scale_index(contexts[0][0],first.number())
|
||||
displacements=(displacement,)
|
||||
except ValueError:
|
||||
displacements=tuple(range(-7,8))
|
||||
direction=(destination>first.number())-(destination<first.number())
|
||||
paths=dict.fromkeys(path for d in displacements for path in library.retrieve(len(contexts),d,direction))
|
||||
low,high=generator.fretboard.single_register((register[0]-7,register[1]+5))
|
||||
low, high = max(low, profile.register[0]), min(high, profile.register[1])
|
||||
result=[]
|
||||
# Reattacks are an explicit primitive, separate from interval catalog paths
|
||||
# (whose zero steps are deliberately excluded). Tied holds remain one event.
|
||||
if all(first.number()%12 in allowed and (fixed is None or fixed==first.number())
|
||||
for _,allowed,fixed in contexts):
|
||||
result.append(((first,)*len(contexts),('primitive:repeated_attack',)))
|
||||
for path in paths:
|
||||
realized=[first]
|
||||
for (scale,allowed,fixed),step in zip(contexts[1:],path.steps):
|
||||
if not scale:break
|
||||
options=advance(scale,realized[-1].number(),step)
|
||||
if not options:break
|
||||
pitch=options[0];number=pitch.number()
|
||||
if not low<=number<=high or number%12 not in allowed:break
|
||||
if fixed is not None and number!=fixed:break
|
||||
if not generator.solo.placement.positions(number,profile):break
|
||||
realized.append(pitch)
|
||||
if len(realized)==len(contexts):result.append((tuple(realized),path.sources))
|
||||
return tuple(result)
|
||||
def realize(self,notes,fixed,purpose,direction,seed,center,incoming=None,hint=()):
|
||||
"""Only completed paths leave this function; internal frontier cuts have no cadence."""
|
||||
if not notes:return ()
|
||||
# Identical musical work is cached without event/provenance IDs.
|
||||
key=(tuple((n.start,n.end,n.pitch if i in fixed else None) for i,n in enumerate(notes)),tuple(sorted(fixed)),purpose,direction,seed,center,incoming.pitch if incoming else None,tuple((n.start,n.end,n.pitch) for n in hint))
|
||||
if key in self.cache:
|
||||
return tuple(tuple(dataclasses.replace(n,pitch=p) for n,p in zip(notes,path)) for path in self.cache[key])
|
||||
width=vocabulary.data('solo')['search']['route_frontier'];states=[(0.,())];count=len(notes)
|
||||
for i,n in enumerate(notes):
|
||||
options=(n.pitch,) if i in fixed else self.pitches(n.start,n.end)
|
||||
region=self.harmony.at(n.start)
|
||||
pitch_order={p:policy.stable_rank(seed,('pitch',i,p)) for p in options}
|
||||
following=[]
|
||||
for cost,path in states:
|
||||
prior=path[-1] if path else incoming.pitch if incoming else None
|
||||
phase=i/max(1,count-1)
|
||||
sign=1 if direction=='up' or direction=='arch' and phase<.5 or direction=='valley' and phase>=.5 else -1
|
||||
target=next((notes[j].pitch for j in sorted(fixed) if j>i),None)
|
||||
choices=[]
|
||||
for pitch in options:
|
||||
number=pitch.number();motion=number-prior.number() if prior else 0
|
||||
if not self.harmony.profile.permits(number) or prior and abs(motion)>12:continue
|
||||
if prior and i not in fixed:
|
||||
try:
|
||||
displacement=types.scale_index(region.scale,number)-types.scale_index(region.scale,prior.number())
|
||||
if displacement and displacement not in self.steps:continue
|
||||
except ValueError:
|
||||
pass # Changed collection: retain the actual incoming pitch.
|
||||
local=max(0,abs(number-center)-10)/12
|
||||
if i not in fixed:
|
||||
local+=max(0,abs(motion)-7)/3
|
||||
if prior:
|
||||
span=region
|
||||
chordal=number%12 in span.roles.basic
|
||||
# Arpeggios expand during the gesture; the last portion connects to its goal.
|
||||
if purpose=='arpeggio' and phase<.65:local+=(0 if chordal else 1)+(.4 if abs(motion)<3 else 0)
|
||||
elif purpose=='neighbor':local+=max(0,abs(motion)-2)/2
|
||||
else:local+=max(0,abs(motion)-3)/3
|
||||
desired=sign
|
||||
if hint and 0<i<len(hint):
|
||||
step=hint[i].pitch.number()-hint[i-1].pitch.number()
|
||||
if purpose=='answer':step=-step
|
||||
desired=(step>0)-(step<0)
|
||||
if purpose in ('return','head') and ((motion>0)-(motion<0))!=desired:continue
|
||||
local+=abs(abs(motion)-abs(step))/12
|
||||
if purpose=='neighbor':desired=sign if i%2 else -sign
|
||||
local+=.35 if motion*desired<0 else .2 if motion==0 else 0
|
||||
if len(path)>=2 and path[-2]==prior==pitch:local+=1
|
||||
if target and phase>.6:
|
||||
remaining=max(1,next(j for j in sorted(fixed) if j>i)-i)
|
||||
local+=max(0,abs(target.number()-number)-remaining*2)/3
|
||||
choices.append((local,pitch))
|
||||
# Preserve distinct register/motion alternatives, not merely two nearest notes.
|
||||
for local,pitch in sorted(choices,key=lambda v:(v[0],pitch_order[v[1]]))[:width]:
|
||||
following.append((cost+local,(*path,pitch)))
|
||||
if not following:return ()
|
||||
states=sequences.diverse(following,lambda v:(round(v[0],8),policy.stable_rank(seed,('path',v[1]))),
|
||||
lambda v:((v[1][-1].number()-v[1][0].number()>0),v[1][-1].number()),width)
|
||||
# Where applicable, add complete catalog routes as peers of the contextual route.
|
||||
if 2<=count<=4 and fixed=={0,count-1}:
|
||||
span=self.harmony.at(notes[0].start)
|
||||
if span and span.scale and all(self.harmony.at(n.start).scale==span.scale for n in notes):
|
||||
try:
|
||||
origin=types.scale_index(span.scale,notes[0].pitch.number());displacement=types.scale_index(span.scale,notes[-1].pitch.number())-origin
|
||||
for route in self.index.get((count,displacement),())[:32]:
|
||||
path=tuple(types.scale_pitch(span.scale,origin+x) for x in route.offsets())
|
||||
if all(p in self.pitches(n.start,n.end) for p,n in zip(path,notes)):states=(*states,(0.,path))
|
||||
except ValueError:pass
|
||||
paths=[]
|
||||
for _,path in sorted(states,key=lambda v:(v[0],policy.stable_rank(seed,v[1]))):
|
||||
if path not in paths:paths.append(path)
|
||||
if len(paths)==2:break
|
||||
self.cache[key]=tuple(paths)
|
||||
return tuple(tuple(dataclasses.replace(n,pitch=p) for n,p in zip(notes,path)) for path in paths)
|
||||
|
||||
+38
-181
@@ -1,191 +1,48 @@
|
||||
"""Duration-normalized musical evaluation, independent of search/notation chunks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import fractions
|
||||
import dataclasses
|
||||
|
||||
import generator.solo.policy
|
||||
import generator.solo.types
|
||||
|
||||
def passages(plan):
|
||||
if plan.passages:
|
||||
return plan.passages
|
||||
end = plan.end - plan.release
|
||||
active = (generator.solo.types.ActivitySpan(plan.start, end, 'active', plan.activity, fractions.Fraction(3, 4)),) if end > plan.start else ()
|
||||
return active + ((generator.solo.types.ActivitySpan(end, plan.end, 'space', fractions.Fraction(), fractions.Fraction()),) if plan.release else ())
|
||||
|
||||
def intervals(choices, start, end):
|
||||
return tuple((max(start, c.span.start), min(end, c.span.end)) for c in choices if c.scale and c.span.start < end and c.span.end > start)
|
||||
|
||||
def overlap(start, end, intervals):
|
||||
return sum((max(0, min(end, b) - max(start, a)) for a, b in intervals), fractions.Fraction())
|
||||
|
||||
def corridor(pitch, low, high):
|
||||
return max(low - pitch, 0, pitch - high)
|
||||
|
||||
def motif_fidelity(notes, motif, treatment):
|
||||
if treatment in ('quote', 'exact_quote'):
|
||||
return 0.0
|
||||
if treatment == 'rhythmic_reference':
|
||||
return 1.0 if len(notes) < min(2, len(motif.notes)) else 0.0
|
||||
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
|
||||
expected = motif.contour()
|
||||
if not actual or not expected:
|
||||
return 0.0 if len(notes) == len(motif.notes) == 1 else 1.0
|
||||
count = min(2, len(actual), len(expected)) if treatment == 'opening_reference' else max(1, len(actual) // 2) if treatment == 'answer' else len(actual)
|
||||
errors = []
|
||||
for index in range(count):
|
||||
reference = expected[min(len(expected) - 1, index * len(expected) // max(1, len(actual)))]
|
||||
motion = actual[index]
|
||||
direction = ((reference > 0) - (reference < 0)) != ((motion > 0) - (motion < 0))
|
||||
errors.append(0.75 * direction + 0.25 * min(1, abs(motion - reference) / 12))
|
||||
evidence = min(2, len(expected))
|
||||
return max(sum(errors) / len(errors), max(0, evidence - len(actual)) / evidence)
|
||||
"""One authoritative evaluation of completed phrases, never guitar travel."""
|
||||
from dataclasses import dataclass
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary
|
||||
from generator.solo import types
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Evaluation:
|
||||
"""Prepare fixed harmonic windows once; only soft penalties use float time."""
|
||||
def __init__(self, plan, choices, until=None):
|
||||
self.plan = plan
|
||||
self.until = plan.end if until is None else until
|
||||
self.duration = max(float(self.until - plan.start), 0.001)
|
||||
self.windows = []
|
||||
self.required = []
|
||||
for passage in passages(plan):
|
||||
end = min(passage.end, self.until)
|
||||
if end <= passage.start:
|
||||
continue
|
||||
active = intervals(choices, passage.start, end)
|
||||
length = float(sum((b - a for a, b in active), fractions.Fraction()))
|
||||
if length:
|
||||
self.windows.append((float(passage.start), float(end), tuple((float(a), float(b)) for a, b in active), length, float(passage.activity) * length, float(passage.occupancy), 0.5 if self.until < passage.end else 0))
|
||||
if passage.end <= self.until:
|
||||
protected = plan.treatment in ('quote', 'exact_quote', 'rhythmic_reference') and passage.end <= plan.start + plan.motif.duration
|
||||
authored_silence = protected and not any(overlap(plan.start + n.start, plan.start + n.end, active) for n in plan.motif.notes)
|
||||
self.required.append((passage, active, authored_silence))
|
||||
self.landmarks = tuple((float(p.earliest), float(p.latest), p.low, p.high, p.direction) for p in plan.landmarks if p.latest <= self.until)
|
||||
def feasible(self, notes):
|
||||
# Hard silence/space requirements retain exact rational boundaries.
|
||||
for passage, active, authored_silence in self.required:
|
||||
if passage.intent == 'space':
|
||||
if any(n.start < passage.end and n.end > passage.start for n in notes):
|
||||
return False
|
||||
elif active and not authored_silence and not any(n.start < b and n.end > a for n in notes for a, b in active):
|
||||
return False
|
||||
return True
|
||||
def features(self, notes, *, previous=None, movement=0):
|
||||
notes = generator.solo.types.sounding_notes(notes)
|
||||
sounded = tuple((float(n.start), float(n.end), n.pitch.number()) for n in notes)
|
||||
activity, occupancy = 0.0, 0.0
|
||||
for start, end, active, length, expected, occupied_goal, tolerance in self.windows:
|
||||
attacks = sum(start <= a < end and any(x <= a < y for x, y in active) for a, _, _ in sounded)
|
||||
occupied = sum(max(0, min(b, y) - max(a, x)) for a, b, _ in sounded for x, y in active)
|
||||
deviation = max(0, abs(attacks - expected) - max(tolerance, generator.solo.policy.DEFAULT.activity_deadband * length / self.duration))
|
||||
activity += length * min(1, deviation / max(1, expected))
|
||||
occupancy += length * max(0, abs(occupied / length - occupied_goal) - generator.solo.policy.DEFAULT.occupancy_deadband)
|
||||
if self.plan.treatment in ('lead', 'source_variation', 'thematic_recomposition'):
|
||||
# One broad range over the complete thought; quiet bars are not failed
|
||||
# local quotas. Missing harmony and explicit release are excluded here.
|
||||
active = tuple(window for window in self.windows if window[4] > 0)
|
||||
length = sum(w[3] for w in active)
|
||||
attacks = sum(any(a <= onset < b for w in active for a, b in w[2]) for onset, _, _ in sounded)
|
||||
occupied = sum(max(0, min(end, b) - max(start, a)) for start, end, _ in sounded for w in active for a, b in w[2])
|
||||
low, high = generator.solo.policy.DEFAULT.lead_activity_range
|
||||
activity = length * corridor(attacks / max(length, .001), low, high) / high
|
||||
low, high = generator.solo.policy.DEFAULT.lead_occupancy_range
|
||||
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)
|
||||
durations = [max(0, min(b, end) - max(a, start)) for a, b, _ in sounded]
|
||||
register = sum(length * min(1, corridor(pitch, *self.plan.register) / 12) for (_, _, pitch), length in zip(sounded, durations)) / max(sum(durations), 0.001)
|
||||
motions = [b[2] - a[2] for a, b in zip(sounded, sounded[1:])]
|
||||
contour = sum(min(1, max(0, abs(m) - 5) / 12) for m in motions) / max(1, len(motions))
|
||||
repetition = sum(a == b == 0 for a, b in zip(motions, motions[1:])) / max(1, len(motions) - 1)
|
||||
landmarks = []
|
||||
for earliest, latest, low, high, direction in self.landmarks:
|
||||
prior = next((n for n in reversed(sounded) if n[0] < earliest), None)
|
||||
landmarks.append(min((0.75 * min(1, corridor(n[2], low, high) / 12) + 0.25 * bool(prior and direction and (n[2] - prior[2]) * direction <= 0) for n in sounded if earliest <= n[0] <= latest), default=1))
|
||||
motif_notes = tuple(n for n in notes if n.role in ('motif', 'quote'))
|
||||
connection = min(1, abs(sounded[0][2] - previous.pitch.number()) / 12) if sounded and previous else 0
|
||||
return {'activity': activity / self.duration, 'occupancy': occupancy / self.duration, 'register': register, 'contour': contour, 'repetition': repetition, 'landmarks': sum(landmarks) / max(1, len(landmarks)), 'connection': connection, 'movement': min(1, movement / max(1, len(notes)) / 2000), 'fidelity': motif_fidelity(motif_notes, self.plan.motif, self.plan.treatment) if motif_notes else 0}
|
||||
|
||||
def features(plan, notes, choices, *, until=None, previous=None, movement=0):
|
||||
return Evaluation(plan, choices, until).features(notes, previous=previous, movement=movement)
|
||||
|
||||
def value(features):
|
||||
return sum(weight * features.get(name, 0) for name, weight in generator.solo.policy.DEFAULT.weights)
|
||||
|
||||
def feasible(plan, notes, choices, until):
|
||||
return Evaluation(plan, choices, until).feasible(notes)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MusicalEvaluation:
|
||||
version: str
|
||||
scope: tuple
|
||||
valid: bool
|
||||
pending: tuple
|
||||
raw: dict
|
||||
denominators: dict
|
||||
weights: dict
|
||||
contributions: dict
|
||||
total: float
|
||||
error: str = ''
|
||||
denominators: dict
|
||||
|
||||
def signature(notes):
|
||||
ns=types.sounding_notes(notes)
|
||||
if not ns:return ()
|
||||
origin=ns[0].start;pitch=ns[0].pitch.number()
|
||||
return tuple((n.start-origin,n.end-n.start,n.pitch.number()-pitch) for n in ns)
|
||||
|
||||
MUSICAL_WEIGHTS = {'rhythm':3., 'destination':2., 'harmony':2.,
|
||||
'register':1., 'repetition':1., 'activity':1., 'difficulty':.5}
|
||||
|
||||
|
||||
def musical(plan, draft, notes, incoming, harmony, *, travel=0, weights=None, bar=None):
|
||||
"""The sole final evaluator of the active thematic composer.
|
||||
|
||||
Scores are normalized on complete sounding events, never notation fragments.
|
||||
Contracts are validity requirements, not additional similarity rewards.
|
||||
Retrieval estimates are deliberately not added to this result.
|
||||
"""
|
||||
from generator.solo import drafts, groups, harmonic, phrasing, types
|
||||
notes=types.sounding_notes(notes)
|
||||
bar=getattr(harmony,'bar',fractions.Fraction(4)) if bar is None else bar
|
||||
from generator import vocabulary
|
||||
weights=dict(vocabulary.data('grammar').get('evaluation',{}).get('weights',MUSICAL_WEIGHTS) if weights is None else weights)
|
||||
if set(weights)!=set(MUSICAL_WEIGHTS):raise ValueError('unknown or missing evaluation contribution')
|
||||
checked=harmony.evaluate(notes, incoming.treatments, until=plan.end, complete=True,
|
||||
enclosures=draft.enclosures, expressions=draft.expressions)
|
||||
identity,identity_error=drafts.contract_valid(draft,notes)
|
||||
anchors,anchor_error=harmonic.anchors_valid(draft.anchors,notes,plan.end,complete=True,closure=plan.closure)
|
||||
feasible=Evaluation(plan,harmony.choices).feasible(notes)
|
||||
valid=checked.valid and identity and anchors and feasible
|
||||
error=checked.error or identity_error or anchor_error or ('' if feasible else 'activity intent not fulfilled')
|
||||
span=float(plan.end-plan.start); sounded=sum(float(n.end-n.start) for n in notes)
|
||||
islands=sum(a.end<b.start and b.end<c.start and b.end-b.start<=fractions.Fraction(1,2)
|
||||
for a,b,c in zip(notes,notes[1:],notes[2:]))
|
||||
# Rests at the edges can be entrance/release. Long internal gaps need a
|
||||
# completed gesture, rather than receiving a universal occupancy penalty.
|
||||
settled={a.hold_until for a in draft.anchors}
|
||||
broken=sum(max(0,float(b.start-a.end)-1) for a,b in zip(notes,notes[1:])
|
||||
if a.end not in settled and a.end-a.start<2)
|
||||
entry=max(0,abs(notes[0].pitch.number()-incoming.last.pitch.number())-7)/12 if notes and incoming.last else 0.
|
||||
rhythm=min(1.,islands/max(1,len(notes)-2)+broken/max(1,span)+entry/2)
|
||||
# Explicit source groups retain their imported timing contract. Generated
|
||||
# groups are checked before construction, not retroactively quantized here.
|
||||
destinations=[]
|
||||
for anchor in draft.anchors:
|
||||
approach=[n for n in notes if n.start<anchor.earliest][-3:]
|
||||
if approach:
|
||||
distances=[abs(n.pitch.number()-anchor.pitch.number()) for n in approach]
|
||||
# A broad approach may turn; large last-second jumps are a preference,
|
||||
# not a nearest-note mandate for all intermediate events.
|
||||
destinations.append(min(1,max(0,distances[-1]-5)/12))
|
||||
goal=harmony.profile.reference
|
||||
register=sum(float(n.end-n.start)*min(1,max(0,abs(n.pitch.number()-goal)-9)/12)
|
||||
for n in notes)/max(sounded,.001) if goal is not None else 0.
|
||||
history=phrasing.repetition(phrasing.heard(notes,draft.relationship),incoming.history,bar) if notes else 0.
|
||||
activity=Evaluation(plan,harmony.choices).features(notes)['activity']
|
||||
raw={'rhythm':rhythm,'destination':sum(destinations)/max(1,len(destinations)),
|
||||
'harmony':checked.exposure,'register':register,'repetition':min(1,history),
|
||||
'activity':activity,'difficulty':min(1,travel/max(1,len(notes))/2000)}
|
||||
contributions={name:weights[name]*value for name,value in raw.items()}
|
||||
return MusicalEvaluation('joint-musical-1',(plan.start,plan.end),valid,checked.pending,raw,
|
||||
{'span':span,'sounding':sounded,'attacks':len(notes),'destinations':len(destinations)},weights,
|
||||
contributions,sum(contributions.values()) if valid else float('inf'),error)
|
||||
def evaluate(phrase,incoming,history,center,weights=None):
|
||||
ns=types.sounding_notes(phrase.notes); span=float(phrase.end-phrase.start)
|
||||
weights=dict(vocabulary.data('solo')['weights'] if weights is None else weights)
|
||||
if set(weights)!={'purpose','continuity','repetition','register'}:raise ValueError('Unknown musical contribution')
|
||||
if not ns:
|
||||
valid=phrase.purpose=='space';raw=dict.fromkeys(weights,0.)
|
||||
else:
|
||||
valid=True
|
||||
movement=[b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:])]
|
||||
# Declared expansion permits arpeggio leaps; avoid unprepared octave flailing.
|
||||
purpose=sum(max(0,abs(v)-7)/12 for v in movement)/max(1,len(movement))
|
||||
oscillation=sum(a==-b==c and a!=0 for a,b,c in zip(movement,movement[1:],movement[2:]))
|
||||
purpose+=max(0,oscillation-1)/max(1,len(movement))
|
||||
if phrase.destination:
|
||||
prior=next((n for n in reversed(ns) if n.end<=phrase.destination.earliest),None)
|
||||
if prior:purpose+=max(0,abs(prior.pitch.number()-phrase.destination.pitch.number())-3)/12
|
||||
continuity=max(0,abs(ns[0].pitch.number()-incoming.pitch.number())-5)/12 if incoming else 0.
|
||||
current=signature(ns[:6]); repetition=0.
|
||||
for age,old in enumerate(reversed(history[-4:])):
|
||||
if len(current)>=3 and current==signature(old.notes[:6]):repetition+=1/(age+1)
|
||||
if phrase.purpose=='return':repetition=max(0,repetition-.5)
|
||||
duration=sum(float(n.end-n.start) for n in ns)
|
||||
register=sum(float(n.end-n.start)*max(0,abs(n.pitch.number()-center)-10)/12 for n in ns)/max(duration,.001)
|
||||
raw={'purpose':min(1,purpose),'continuity':min(1,continuity),'repetition':min(1,repetition),'register':min(1,register)}
|
||||
contributions={k:raw[k]*weights[k] for k in weights}
|
||||
return Evaluation('phrase-musical-1',valid,raw,weights,contributions,sum(contributions.values()) if valid else float('inf'),{'span':span,'attacks':len(ns),'sounding':sum(float(n.end-n.start) for n in ns)})
|
||||
|
||||
@@ -82,41 +82,6 @@ class Library:
|
||||
object.__setattr__(self, 'chains', {})
|
||||
def connecting(self, count: int, displacement: int | None = None) -> tuple[Sequence, ...]:
|
||||
return tuple(s for s in self.paths.get(count, ()) if displacement is None or s.offsets()[-1] == displacement)
|
||||
def chained(self, count):
|
||||
return self.retrieve(count)
|
||||
def retrieve(self, count, displacement=0, direction=0):
|
||||
key = (count, displacement, direction)
|
||||
if key in self.chains:
|
||||
return self.chains[key]
|
||||
if not 1 <= count <= 16:
|
||||
return ()
|
||||
states, remaining = (Path((), ()),), count - 1
|
||||
while remaining:
|
||||
size = min(3, remaining)
|
||||
options = self.connecting(size + 1)
|
||||
if not options:
|
||||
size = next((n for n in range(size - 1, 0, -1) if self.connecting(n + 1)), 0)
|
||||
if not size:
|
||||
return ()
|
||||
options = self.connecting(size + 1)
|
||||
desired = displacement * (count - 1 - remaining + size) / max(1, count - 1)
|
||||
def rank(path):
|
||||
offsets = path.offsets()
|
||||
motion = offsets[-1]
|
||||
return abs(motion - desired) + (2 if direction and motion * direction < 0 else 0), max(offsets) - min(offsets), max(map(abs, path.steps), default=0), path.sources
|
||||
# Retrieve from the entire authored vocabulary, conditioned on each prefix.
|
||||
following = []
|
||||
for prior in states:
|
||||
if count > 4:
|
||||
local = self.retrieve(size + 1, round(desired - prior.offsets()[-1]), direction)
|
||||
paths = (Path(prior.steps + s.steps, prior.sources + s.sources) for s in local)
|
||||
else:
|
||||
paths = (Path(prior.steps + s.steps, prior.sources + (s.id,)) for s in options)
|
||||
following.extend(diverse(paths, rank, lambda p: family(p.steps), generator.solo.policy.DEFAULT.retrieval))
|
||||
states = diverse(following, rank, lambda p: (family(p.steps), (p.offsets()[-1] > 0) - (p.offsets()[-1] < 0)), generator.solo.policy.DEFAULT.retrieval)
|
||||
remaining -= size
|
||||
self.chains[key] = states
|
||||
return states
|
||||
|
||||
def read(path: pathlib.Path, identifier: str, count: int) -> Sequence:
|
||||
document = generator.musicxml.load_musicxml(path)
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""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))
|
||||
@@ -1,567 +0,0 @@
|
||||
"""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
|
||||
import generator.vocabulary
|
||||
from generator.solo import types, policy, sequences, routes, features as references, similarity, phrasing, profiles, groups as rhythm_groups
|
||||
|
||||
|
||||
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 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,harmony.profile.register,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,
|
||||
history=incoming.history,returns=incoming.returns,closures=incoming.closures,register_peak=incoming.register_peak)
|
||||
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),harmony.profile.register,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,harmony.profile.register,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,
|
||||
history=incoming.history,returns=incoming.returns,closures=incoming.closures,register_peak=incoming.register_peak)
|
||||
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,6,7,8)):
|
||||
group=melody[i:i+count]
|
||||
if len(group)!=count or group[-1].end-group[0].start>max(2*bar,F(3)):
|
||||
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 generator.vocabulary.role().get('preferred_features',('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=set(rhythm_groups.signature(ref.notes))
|
||||
intervals={b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])}
|
||||
preferred=generator.vocabulary.role().get('preferred_features',[])
|
||||
preference=preferred.index(ref.treatment) if ref.treatment in preferred else 0
|
||||
turn=any(a*b<0 for a,b in zip(ref.notes and tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])),tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:]))[1:]))
|
||||
return (-min(4,len(intervals))-.3*turn+.15*preference,-min(3,len(rhythm)),ref.id)
|
||||
# Preserve different contracts first. Combining treatment with form position
|
||||
# created many categories of the preferred treatment and could crowd every
|
||||
# rhythm/contour seed out before a single pitch was constructed.
|
||||
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':('contour',),'fragment':('rhythm',),'callback':('rhythm','pitches'),
|
||||
'return':('rhythm','intervals'), 'develop_rhythm':('rhythm',),
|
||||
'develop_contour':('intervals',), 'continue':(), 'connect':(), 'extension':('contour',)}[treatment]
|
||||
original=reference.notes
|
||||
authored=next((r for r in generator.vocabulary.data('grammar')['transformations'] if r['id']==treatment),None)
|
||||
if authored:required=tuple(authored['preserve'])
|
||||
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 rhythmic_purpose(operation, terminal=False):
|
||||
if terminal:return 'arrival'
|
||||
if operation in ('introduce','callback','return','repeat_with_harmonic_adaptation'):return 'entrance'
|
||||
if operation in ('answer','fragment'):return 'answer'
|
||||
return 'connect' if operation in ('continue','connect') else 'continue'
|
||||
|
||||
|
||||
def rhythm_cost(pattern,length,purpose):
|
||||
"""Evaluate a rhythmic job, not resemblance to any target score or density."""
|
||||
if not pattern:return float('inf')
|
||||
gaps=[b[0]-a[1] for a,b in zip(pattern,pattern[1:])]
|
||||
isolated=sum(b-a<=F(1,2) and prev[1]<a and b<nxt[0]
|
||||
for prev,(a,b),nxt in zip(pattern,pattern[1:],pattern[2:]))
|
||||
values={b-a for a,b in pattern}
|
||||
# Equal eighths can be a single directional run. Uniformity alone is not a
|
||||
# musical defect; aimless long-value packets are addressed by construction.
|
||||
uniform=.15 if len(pattern)>=4 and len(values)==1 and next(iter(values))>=1 else 0.
|
||||
if purpose in ('continue','connect'):
|
||||
# Silence may articulate an idea; disconnected tiny islands do not carry
|
||||
# unfinished motion. No minimum occupancy or chorus attack quota.
|
||||
return isolated+.25*float(sum(gaps,F())/length)+uniform
|
||||
if purpose=='arrival':
|
||||
return isolated+float(max(F(),F(1)- (pattern[-1][1]-pattern[-1][0])))/2+uniform
|
||||
return isolated+uniform
|
||||
|
||||
|
||||
def rhythm_windows(library,meter,length,context,purpose='continue'):
|
||||
from generator.solo import drafts
|
||||
tempo=getattr(context,'tempo',120)
|
||||
key=('theme-rhythm',id(library),meter,length,tempo,purpose)
|
||||
if key not in context.pools:
|
||||
patterns={}
|
||||
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
||||
if length>bar:
|
||||
# The two-bar catalog contains quarter-note masks. Include combinations
|
||||
# of the richer one-bar material as complete timing alternatives, rather
|
||||
# than accidentally excluding eighths/dotted values by snippet length.
|
||||
# Pitch paths and feature contracts span the assembled rhythm's seams.
|
||||
split=bar
|
||||
for left,left_id in rhythm_windows(library,meter,split,context,'continue' if purpose=='arrival' else purpose):
|
||||
for right,right_id in rhythm_windows(library,meter,length-split,context,purpose):
|
||||
combined=(*left,*((a+split,b+split) for a,b in right))
|
||||
if len(combined)<=16:patterns.setdefault(combined,left_id+' + '+right_id)
|
||||
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 and rhythm_groups.valid(selected):patterns.setdefault(selected,identifier)
|
||||
# Authored cells can start late and continue over computational barlines.
|
||||
for cell in generator.vocabulary.rhythm_cells(tempo,purpose):
|
||||
identifier,pattern,total=cell.id,cell.pattern,cell.duration
|
||||
if total>length:continue
|
||||
for offset in ((F(),) if cell.entry=='pickup' else (F(),F(1,2),max(F(),length-total))):
|
||||
if offset+total>length:continue
|
||||
head=tuple((a+offset,b+offset) for a,b in pattern)
|
||||
# Keep representative compatible continuations; never let dictionary
|
||||
# insertion order secretly choose the only tail for an authored cell.
|
||||
tails=sequences.diverse(tuple(patterns),lambda p:(rhythm_cost(p,length,purpose),policy.stable_rank('rhythm-tail',p)),
|
||||
lambda p:(len(p),p[-1][1]-p[-1][0]),2) or ((),)
|
||||
for tail in tails:
|
||||
if any(a<offset+total<b for a,b in tail):continue
|
||||
continuation=tuple((a,b) for a,b in tail if a>=offset+total)
|
||||
if cell.exit=='continue' and offset+total<length and (not continuation or continuation[0][0]!=offset+total):continue
|
||||
combined=(*head,*continuation)
|
||||
if 2<=len(combined)<=16 and rhythm_groups.valid(combined):patterns.setdefault(combined,'authored:'+identifier)
|
||||
context.pools[key]=sequences.diverse(tuple(patterns.items()),
|
||||
lambda x:(rhythm_cost(x[0],length,purpose)+(0 if x[1].startswith('authored:') else .05),policy.stable_rank('rhythm-window',x[0])),
|
||||
lambda x:(x[1].startswith('authored:'),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, ending=None):
|
||||
length=finish-start; values={}
|
||||
purpose=rhythmic_purpose(operation,terminal)
|
||||
if home and operation in ('repeat_with_harmonic_adaptation','return'):
|
||||
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)
|
||||
fixed_rhythm=ref.treatment in ('rhythmic_seed','opening_fragment','callback') or operation in ('fragment','repeat_with_harmonic_adaptation','return','develop_rhythm')
|
||||
if fixed_rhythm:
|
||||
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,purpose):
|
||||
values[(*prefix,*((a+duration,b+duration) for a,b in tail))]=ref.id+' + '+label
|
||||
else:
|
||||
values.update(rhythm_windows(rhythms,meter,length,context,purpose))
|
||||
if home and operation=='answer':
|
||||
for p,label in rhythm_windows(rhythms,meter,length,context,purpose):values.setdefault(p,label)
|
||||
if not fixed_rhythm:
|
||||
# Develop the heard timing, preserving total duration. Eighth/quarter/dotted
|
||||
# alternatives are actual contracts, not failed copies of a rhythmic seed.
|
||||
for original,label in tuple(values.items()):
|
||||
movement=generator.vocabulary.data('melodic_vocabulary')['motion_subdivision']
|
||||
for i,(a,b) in enumerate(original[:-1]):
|
||||
pulse=F(movement['pulse'])
|
||||
if b-a<F(movement['minimum_value']) or (a/pulse).denominator!=1 or ((b-a)/pulse).denominator!=1:continue
|
||||
moving=tuple((a+j*pulse,a+(j+1)*pulse) for j in range(int((b-a)/pulse)))
|
||||
revised=(*original[:i],*moving,*original[i+1:])
|
||||
if len(revised)<=movement['max_attacks']:values[revised]=label+'; moving continuation'
|
||||
for i,(a,b) in enumerate(original):
|
||||
if b-a==1:
|
||||
values[(*original[:i],(a,a+F(1,2)),(a+F(1,2),b),*original[i+1:])]=label+'; diminution'
|
||||
for i,((a,b),(c,d)) in enumerate(zip(original,original[1:])):
|
||||
if b!=c or any((v*2).denominator!=1 for v in (a,b,c,d)):continue
|
||||
for split in (a+F(1,2),a+F(3,2),d-F(1,2)):
|
||||
if a<split<d:
|
||||
values[(*original[:i],(a,split),(split,d),*original[i+2:])]=label+'; rhythmic alteration'
|
||||
if ending:
|
||||
arrivals={}
|
||||
arrival=ending.earliest-start
|
||||
for original,label in values.items():
|
||||
try: revised=rhythm_groups.broaden(original,arrival,ending.hold_until-start)
|
||||
except ValueError: revised=()
|
||||
if revised and len(revised)>=len(ref.notes):
|
||||
arrivals[revised]=label+'; planned sounding destination'
|
||||
values=arrivals
|
||||
values={p:label for p,label in values.items() if rhythm_groups.valid(p) or fixed_rhythm and p==prefix}
|
||||
return sequences.diverse(tuple(values.items()),lambda x:(rhythm_cost(x[0],length,purpose),policy.stable_rank(ref.id,x[0])),
|
||||
lambda x:(len(x[0]),tuple(b-a for a,b in x[0][:3]),x[0][0][0]),policy.DEFAULT.theme_rhythm_alternatives)
|
||||
|
||||
|
||||
def pitches(harmony,note,base,*,members=False,limit=6,incoming=None):
|
||||
from generator.solo.leads import available
|
||||
domain=available(harmony,note.start,note.end,members=members or note.end-note.start>=2)
|
||||
if members:
|
||||
return phrasing.destination_options(domain,lambda p:(phrasing.destination_cost(p,note.start,note.end,harmony,incoming,base),p.number()),
|
||||
lambda p:p,lambda p:note.start,harmony,limit)
|
||||
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)
|
||||
|
||||
|
||||
@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,...] = ()
|
||||
start: F = F()
|
||||
end: F = F()
|
||||
expressions: tuple[types.ExpressionContract,...] = ()
|
||||
|
||||
|
||||
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None,allow_expressions=True):
|
||||
"""Joint whole-gesture pitch/rhythm actions, conditioned on the actual entry.
|
||||
|
||||
A complete route is proposed before thematic evidence is checked. No motif
|
||||
prefix is spliced to an independently cheapest suffix. Ordinary search ends
|
||||
carry no chord-tone obligation; long-note and destination contracts still do.
|
||||
"""
|
||||
from generator.solo import leads
|
||||
key=('joint-gesture-1',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,
|
||||
home,operation,ending,goal,batch,allow_expressions,getattr(context,'expressions_enabled',True),id(harmony),id(library),id(rhythms))
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits']+=1;return context.results[key]
|
||||
trace=context.diagnostics.setdefault('operations',{}).setdefault(operation,
|
||||
dict(eligible=0,proposed=0,valid=0,retained=0,jobs=0,pruned_jobs=0,rejected_by_rule=0))
|
||||
trace['eligible']+=1
|
||||
require_feature=operation not in ('continue','connect')
|
||||
count=len(ref.notes) if require_feature else 0
|
||||
jobs=[]
|
||||
for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal,ending=ending):
|
||||
if len(pattern)<max(2,count) or len(pattern)>16:continue
|
||||
provenance=tuple(dict.fromkeys(s for n in ref.notes for s in n.sources))
|
||||
templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,provenance,
|
||||
'theme' if require_feature else 'connection',event_id=f'{ref.id}:{operation}:{start}:{i}') for i,(a,b) in enumerate(pattern))
|
||||
base=incoming.last.pitch.number() if incoming.last else harmony.profile.reference or ref.notes[0].pitch.number()
|
||||
firsts=pitches(harmony,templates[0],base,limit=6)
|
||||
if incoming.last is None:
|
||||
offsets=generator.vocabulary.role().get('register_alternatives',[-7,0,7])
|
||||
firsts=tuple(dict.fromkeys(p for offset in offsets for p in pitches(harmony,templates[0],base+offset,limit=2)))
|
||||
targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,
|
||||
members=False,limit=policy.DEFAULT.theme_destinations,incoming=incoming)
|
||||
for first in firsts:
|
||||
for target in targets:
|
||||
motion=ref.notes[-1].pitch.number()-ref.notes[0].pitch.number()
|
||||
if operation=='answer':motion=-motion
|
||||
# Rank purposeful direction separately from entry continuity. No reward
|
||||
# for minimizing every interval or for repeatedly visiting the midpoint.
|
||||
rank=max(0,abs(first.number()-base)-7)/12
|
||||
rank+=.1*min(1,abs((target.number()-first.number())-motion)/12)
|
||||
jobs.append((rank,templates,label,first,target))
|
||||
selected=sequences.diverse(jobs,lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4]))),
|
||||
lambda j:(tuple(n.end-n.start for n in j[1]),j[3].number()//12,
|
||||
(j[4].number()>j[3].number())-(j[4].number()<j[3].number())),policy.DEFAULT.theme_route_jobs*(batch+1))
|
||||
trace['jobs']+=len(jobs);trace['pruned_jobs']+=len(jobs)-len(selected)
|
||||
result=[]
|
||||
from generator.solo import gestures
|
||||
scheduled=[]
|
||||
for rank,templates,label,first,target in selected:
|
||||
timed=(dataclasses.replace(templates[0],pitch=first),*templates[1:-1],dataclasses.replace(templates[-1],pitch=target))
|
||||
scheduled.append((rank,gestures.Proposal(timed,(),'direct'),label))
|
||||
if allow_expressions and getattr(context,'expressions_enabled',True):
|
||||
for rank,plain,label in tuple(scheduled[:4]):
|
||||
scheduled.extend((rank,p,label+'; '+p.purpose) for p in gestures.select(plain.notes,harmony,start,finish,ending,limit=4))
|
||||
funnel=context.diagnostics.setdefault('joint_gestures',{})
|
||||
def record(purpose,stage,count=1):
|
||||
values=funnel.setdefault(purpose,dict(proposed=0,retained_jobs=0,valid_routes=0,retained_units=0))
|
||||
values[stage]+=count
|
||||
for _,p,_ in scheduled:record(p.purpose,'proposed')
|
||||
scheduled=sequences.diverse(scheduled,lambda v:(v[0],policy.stable_rank(ref.id,(v[1].purpose,tuple((n.start,n.end,n.pitch) for n in v[1].notes)))),
|
||||
lambda v:tuple(c.kind for c in v[1].contracts) or ('direct',),policy.DEFAULT.theme_route_jobs*(batch+1))
|
||||
for _,proposal,label in scheduled:
|
||||
record(proposal.purpose,'retained_jobs')
|
||||
timed=proposal.notes;first=timed[0].pitch;target=timed[-1].pitch;templates=timed
|
||||
if not max(2,count)<=len(timed)<=16:continue
|
||||
fixed={timed[-1].event_id}|{n.event_id for n in timed if n.role=='arrival'}
|
||||
proposed=list(routes.candidates(timed,fixed,harmony,library,harmony.profile.register,
|
||||
operation+' '+ref.id,purpose=rhythmic_purpose(operation,terminal),expressions=proposal.contracts))
|
||||
# A substantial literal return is a musical action, not merely the hope
|
||||
# that a general interval search will accidentally reproduce the theme.
|
||||
if proposal.purpose=='direct' and require_feature and len(templates)==len(ref.notes):
|
||||
delta=first.number()-ref.notes[0].pitch.number()
|
||||
actual=[]
|
||||
for template,n in zip(templates,ref.notes):
|
||||
options=leads.available(harmony,template.start,template.end,members=template.end-template.start>=2)
|
||||
p=next((p for p in options if p.number()==n.pitch.number()+delta),None)
|
||||
if p is None:break
|
||||
actual.append(dataclasses.replace(template,pitch=p))
|
||||
if len(actual)==len(templates) and actual[-1].pitch==target:
|
||||
proposed.append(types.RouteCandidate('theme:'+ref.id,tuple(actual),actual[-1],(),operation,(ref.id,)))
|
||||
for route in proposed:
|
||||
trace['proposed']+=1
|
||||
actual=route.notes
|
||||
contract=feature(ref,actual[:count],treatment=operation if operation!='introduce' else None) if count else None
|
||||
if contract and references.feature_error(contract,actual):
|
||||
trace['rejected_by_rule']+=1;continue
|
||||
contracts=proposal.bind(actual)
|
||||
checked=harmony.evaluate(actual,until=finish,complete=True,expressions=contracts)
|
||||
if not checked.valid:trace['rejected_by_rule']+=1;continue
|
||||
trace['valid']+=1
|
||||
record(proposal.purpose,'valid_routes')
|
||||
entry=max(0,abs(first.number()-incoming.last.pitch.number())-7)/12 if incoming.last else 0.
|
||||
score=2*checked.exposure+entry+.1*routes.path_cost(actual)
|
||||
score+=.25*rhythm_cost(tuple((n.start-start,n.end-start) for n in actual),finish-start,rhythmic_purpose(operation,terminal))
|
||||
result.append(Unit(actual,contract,ref,operation,ending,score,label,(route,),start,finish,contracts))
|
||||
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
|
||||
chosen=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
|
||||
lambda u:(routes.path_family(u.notes,harmony),u.notes[0].pitch.number()//12,
|
||||
tuple(n.end-n.start for n in u.notes),tuple(c.kind for c in u.expressions)),policy.DEFAULT.theme_unit_alternatives)
|
||||
trace['retained']+=len(chosen)
|
||||
for u in chosen:
|
||||
record(u.source.split('; ')[-1] if '; ' in u.source else 'direct','retained_units')
|
||||
context.diagnostics['generated_candidates']+=len(chosen)
|
||||
context.results[key]=chosen
|
||||
return chosen
|
||||
|
||||
|
||||
def home_reference(home,operation):
|
||||
# A return refers to the opening the listener heard, not whichever later
|
||||
# three-note turn happens to rank best. Development may use its smaller core.
|
||||
notes = home.signature or home.notes[:min(4, len(home.notes))]
|
||||
if operation not in ('return', 'repeat_with_harmonic_adaptation'):
|
||||
notes = notes[:3]
|
||||
origin=notes[0].start
|
||||
notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in notes)
|
||||
treatment='return' if operation=='return' else 'rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','fragment','develop_rhythm') else 'contour_seed'
|
||||
return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,treatment)
|
||||
|
||||
|
||||
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),signature=home.notes[:len(unit.reference.notes)])
|
||||
|
||||
|
||||
def operation_choices(home,incoming,cursor,phrase_start,closing):
|
||||
available=generator.vocabulary.data('grammar')['operations']
|
||||
selected=[]
|
||||
for rule in available:
|
||||
op=rule['id']
|
||||
if rule.get('entry_only') and cursor!=phrase_start:continue
|
||||
if rule.get('after_statement') and not home:continue
|
||||
if rule.get('needs_recent') and not incoming.notes:continue
|
||||
selected.append(op)
|
||||
return tuple(selected)
|
||||
|
||||
|
||||
def unit_end(source,cursor,home,ending,bar):
|
||||
"""Bound search by time without cutting the heard signature at a barline."""
|
||||
length=2*bar
|
||||
if home and home.signature:
|
||||
length=home.signature[-1].end
|
||||
while 0<length<bar:
|
||||
length+=home.signature[-1].end
|
||||
length=min(2*bar,max(bar,length))
|
||||
finish=min(source.end,cursor+length)
|
||||
if source.end-finish<bar+2 or ending and ending.earliest<finish:
|
||||
finish=source.end
|
||||
return finish
|
||||
|
||||
|
||||
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False,solo_end=None):
|
||||
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])
|
||||
solo_end=solo_end or max(c.span.end for c in harmony.choices)
|
||||
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]
|
||||
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=6*(batch+1),final=final)
|
||||
if not final:ending_options=(None,*ending_options)
|
||||
states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options]
|
||||
while states and any(not units or units[-1].end<source.end for _,units,_,_,_ in states):
|
||||
next_states=[]
|
||||
for cost,units,home,state,ending in states:
|
||||
cursor=units[-1].end if units else source.start
|
||||
if cursor>=source.end:
|
||||
next_states.append((cost,units,home,state,ending))
|
||||
continue
|
||||
finish=unit_end(source,cursor,home,ending,bar)
|
||||
introducing=home is None
|
||||
if introducing:
|
||||
requests=[(r,'introduce') for r in roots]
|
||||
else:
|
||||
operations=operation_choices(home,state,cursor,source.start,finish==source.end)
|
||||
requests=[(home_reference(home,op),op) for op in operations]
|
||||
# A uniform duration alone is not identifying musical evidence.
|
||||
requests=[(r,op) for r,op in requests if op!='develop_rhythm' or len({n.end-n.start for n in r.notes})>1 or any(a.end!=b.start for a,b in zip(r.notes,r.notes[1:]))]
|
||||
for ref,operation in requests:
|
||||
if introducing and ending and source.end-source.start<2*bar and ending.earliest-cursor<ref.notes[-1].end:
|
||||
# A missing-harmony boundary can leave less than one full statement.
|
||||
# Declare a smaller contour seed; never pretend it proves the full motif.
|
||||
count=min(3,int(2*(ending.earliest-cursor)),len(ref.notes))
|
||||
if count<2:
|
||||
if len(ref.notes)!=1:continue
|
||||
n=dataclasses.replace(ref.notes[0],end=min(ref.notes[0].end,F(1),ending.earliest-cursor))
|
||||
ref=dataclasses.replace(ref,id=ref.id+':constrained_pitch',notes=(n,),treatment='opening_fragment')
|
||||
else:
|
||||
ref=dataclasses.replace(ref,id=ref.id+':constrained_contour',notes=ref.notes[:count],treatment='contour_seed')
|
||||
if operation=='continue' and state.notes:
|
||||
recent=state.notes[-min(3,len(state.notes)):]
|
||||
origin=recent[0].start
|
||||
ref=dataclasses.replace(ref,notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in recent),treatment='contour_seed')
|
||||
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='continue'
|
||||
terminal=bool(ending and finish==source.end)
|
||||
base=state.last.pitch.number() if state.last else ref.notes[0].pitch.number()
|
||||
destination=ending.pitch.number() if ending else phrasing.register_goal(state,part,base,harmony.profile)
|
||||
goal=destination if finish==source.end else round(base+(destination-base)*float((finish-cursor)/(source.end-cursor)))
|
||||
options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation,
|
||||
terminal=terminal,batch=batch,ending=ending if terminal else None,goal=goal,allow_expressions=stage=='elaborated')
|
||||
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)
|
||||
figure=phrasing.heard(unit.notes,operation,unit.anchor,bar)
|
||||
repetition=phrasing.repetition(figure,state.history,bar)
|
||||
appearance=phrasing.appearance_cost(operation,state,cursor,solo_end) if home else 0.
|
||||
history=phrasing.remember(state.history,figure,bar)
|
||||
following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref,
|
||||
history=history,returns=(*state.returns,*((cursor,) if operation=='return' else ())),
|
||||
register_peak=max(state.register_peak,*(n.pitch.number() for n in unit.notes)))
|
||||
next_states.append((cost+float(finish-cursor)*(unit.score+.8*repetition+appearance),(*units,unit),actual_home,following,ending))
|
||||
def rank(value):
|
||||
cost,units,home,state,ending=value
|
||||
elapsed=units[-1].end-source.start
|
||||
return cost/max(.001,float(elapsed)),policy.stable_rank(seed,tuple(u.notes for u in units))
|
||||
states=sequences.diverse(next_states,rank,
|
||||
lambda v:(v[2].id,(v[4].pitch.number(),v[4].duration) if v[4] else 'open',len(v[1][-1].notes),v[1][-1].operation),policy.DEFAULT.theme_continuations)
|
||||
if not states:return ()
|
||||
result=[]
|
||||
for cost,units,home,state,ending 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 if u.feature)
|
||||
# Cell tails are not compulsory melodic arrivals. Only the selected close
|
||||
# has a sounding contract; intermediate turns remain editable.
|
||||
anchors=(ending,) if ending else ()
|
||||
tails=tuple(types.SustainedTail(n.start,n.end,n.pitch.number()) for n in plain if n.role=='arrival')
|
||||
if ending:tails=(*tails,types.SustainedTail(ending.earliest,ending.hold_until,ending.pitch.number()))
|
||||
options=[(plain,(),())]
|
||||
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(n.event_id for n in notes if n.role=='arrival'),(),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),sustained_tails=tails,
|
||||
expressions=tuple(c for u in units for c in u.expressions))
|
||||
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
|
||||
landmarks=(types.Landmark(ending.earliest,ending.latest,ending.pitch.number(),ending.pitch.number(),0,'sounding destination'),) if ending else ()
|
||||
closed=bool(ending and ending.role=='terminal')
|
||||
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,harmony.profile.register,F(5,4),
|
||||
source.end-ending.hold_until if ending else F(),phrasing.activity(source,ending,tuple(n for n in notes if n.role=='arrival')),landmarks,
|
||||
'sustain' if ending else 'connected','closed' if closed else 'open',seed,
|
||||
source.id,source.start,source.end,(anchors,))
|
||||
before_rhythm=draft
|
||||
from generator.solo import gesture_rhythm
|
||||
if stage=='elaborated':
|
||||
draft,rhythm_edits=gesture_rhythm.revise(draft,harmony,incoming.treatments)
|
||||
notes=draft.notes;edits=(*edits,*rhythm_edits);transforms=draft.transformations
|
||||
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
|
||||
if phrase is None:continue
|
||||
history=incoming.history
|
||||
for transform in transforms:
|
||||
group=tuple(n for n in notes if n.event_id in transform.events)
|
||||
if group:history=phrasing.remember(history,phrasing.heard(group,transform.operation,ending if ending and group[-1].end==ending.hold_until else None,bar),bar)
|
||||
current=dataclasses.replace(phrase.state,next_destination=phrasing.next_destination(ending,harmony) if ending else incoming.next_destination,home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units),
|
||||
history=history,returns=state.returns,closures=incoming.closures+int(closed),register_peak=max(state.register_peak,*(n.pitch.number() for n in notes)))
|
||||
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),signature=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced[:len(units[0].reference.notes)]))
|
||||
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=(),sustained_tails=())
|
||||
refined=dataclasses.replace(draft,id=identifier+':plain',notes=plain)
|
||||
result.append(dataclasses.replace(phrase,state=current,initial=original,refined=refined,before_rhythm=before_rhythm,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)))
|
||||
from generator.solo import expressions,gesture_rhythm
|
||||
if stage=='elaborated':
|
||||
revisions=[]
|
||||
for base in result:
|
||||
for changed,edit,route in gesture_rhythm.join_variants(base.draft,harmony,library):
|
||||
revised=realization.checked_phrase(base.plan,changed,changed.notes,incoming.treatments,incoming,harmony,tempo,(*base.edits,edit))
|
||||
if revised is None:continue
|
||||
state=dataclasses.replace(base.state,notes=revised.state.notes,last=revised.state.last,
|
||||
motifs=revised.state.motifs,travel=revised.state.travel)
|
||||
history=incoming.history
|
||||
for t in changed.transformations:
|
||||
heard=tuple(n for n in changed.notes if n.event_id in t.events)
|
||||
if heard:history=phrasing.remember(history,phrasing.heard(heard,t.operation,bar=bar),bar)
|
||||
state=dataclasses.replace(state,history=history)
|
||||
retained=tuple(r for r in base.routes if not any(n.start<edit.end and n.end>edit.start for n in r.notes))
|
||||
revisions.append(dataclasses.replace(revised,state=state,initial=base.initial,refined=base.refined,
|
||||
before_rhythm=base.draft,routes=(*retained,route)))
|
||||
context.diagnostics['join_revision_candidates']=context.diagnostics.get('join_revision_candidates',0)+len(revisions)
|
||||
result.extend(revisions)
|
||||
if stage=='elaborated' and getattr(context,'expressions_enabled',True):
|
||||
result=expressions.joint_candidates(result,incoming,harmony,tempo,context,bar=bar)
|
||||
selected=sequences.diverse(result,lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
|
||||
lambda p:(p.state.home.id,p.plan.closure,p.state.last.pitch.number(),tuple(e.kind for e in p.draft.expressions)),policy.DEFAULT.phrase_alternatives)
|
||||
context.diagnostics.setdefault('evaluations',[]).extend(
|
||||
{'id':p.draft.id,'retained':p in selected,'evaluation':dataclasses.asdict(p.evaluation)} for p in sorted(result,key=lambda p:p.score)[:12] if p.evaluation)
|
||||
return selected
|
||||
+40
-274
@@ -1,15 +1,13 @@
|
||||
"""Shared sounding events and completed musical phrases; no draft/cell hierarchy."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
|
||||
import generator.fretboard
|
||||
import dataclasses, fractions, functools
|
||||
import generator.model
|
||||
import generator.fretboard
|
||||
import generator.scales.library
|
||||
import generator.scales.selection
|
||||
import generator.solo.profiles
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, order=True)
|
||||
class Pitch:
|
||||
letter: str
|
||||
@@ -18,6 +16,12 @@ class Pitch:
|
||||
def __post_init__(self):
|
||||
if self.letter not in generator.model.NATURAL or self.alter.denominator != 1:
|
||||
raise ValueError('solo pitches require spelled semitones from the permitted scales')
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def __repr__(self):
|
||||
# Exactly the former dataclass representation: cache formatting without
|
||||
# changing deterministic choice keys or introducing another seed format.
|
||||
return f'Pitch(letter={self.letter!r}, alter={self.alter!r}, octave={self.octave!r})'
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def number(self) -> int:
|
||||
return generator.model.NATURAL[self.letter] + int(self.alter) + 12 * (self.octave + 1)
|
||||
def written(self) -> dict:
|
||||
@@ -48,42 +52,6 @@ def sounding_notes(notes):
|
||||
result.append(note)
|
||||
return tuple(result)
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Motif:
|
||||
id: str
|
||||
notes: tuple[Note, ...]
|
||||
duration: fractions.Fraction
|
||||
context: tuple[str, ...]
|
||||
origin: str = 'melody'
|
||||
def contour(self) -> tuple[int, ...]:
|
||||
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)
|
||||
class TargetEvent:
|
||||
earliest: fractions.Fraction
|
||||
latest: fractions.Fraction
|
||||
pitches: frozenset[int]
|
||||
chord: str
|
||||
reason: str
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ActivitySpan:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
intent: str
|
||||
activity: fractions.Fraction
|
||||
occupancy: fractions.Fraction
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class HarmonicTreatment:
|
||||
id: str
|
||||
@@ -108,13 +76,6 @@ class ExpressionContract:
|
||||
boundary: fractions.Fraction | None = None
|
||||
preparation: Note | None = None
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class LeadIdentity:
|
||||
id: str
|
||||
durations: tuple[fractions.Fraction, ...]
|
||||
degrees: tuple[int, ...]
|
||||
source: str
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Anchor:
|
||||
id: str
|
||||
@@ -130,120 +91,12 @@ class Anchor:
|
||||
hold_until: fractions.Fraction | None = None
|
||||
effect: str = 'local_settle'
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Gesture:
|
||||
id: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
relationship: str
|
||||
treatment: str
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RealizedMotif:
|
||||
id: str
|
||||
parent: str
|
||||
notes: tuple[Note, ...]
|
||||
duration: fractions.Fraction
|
||||
relationship: str
|
||||
treatment: str
|
||||
chord_degrees: tuple[tuple[int, ...], ...] = ()
|
||||
source_start: fractions.Fraction = fractions.Fraction()
|
||||
degree_steps: tuple[int, ...] = ()
|
||||
signature: tuple[Note, ...] = ()
|
||||
def contour(self):
|
||||
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ContinuationState:
|
||||
passage_id: str
|
||||
notes: tuple[Note, ...] = ()
|
||||
motifs: tuple[RealizedMotif, ...] = ()
|
||||
treatments: tuple[HarmonicTreatment, ...] = ()
|
||||
anchors: tuple[str, ...] = ()
|
||||
direction: int = 0
|
||||
last: Note | None = None
|
||||
backbone: int = 0
|
||||
fidelity: tuple[float, ...] = ()
|
||||
travel: int = 0
|
||||
home: RealizedMotif | None = None
|
||||
development: RealizedMotif | None = None
|
||||
themes: tuple[RealizedMotif, ...] = ()
|
||||
selected_anchors: tuple[Anchor, ...] = ()
|
||||
ending: str = ''
|
||||
lead_identity: LeadIdentity | None = None
|
||||
source_reference: SourceReference | None = None
|
||||
callbacks: int = 0
|
||||
history: tuple[HeardFigure, ...] = ()
|
||||
returns: tuple[fractions.Fraction, ...] = ()
|
||||
closures: int = 0
|
||||
register_peak: int = 0
|
||||
next_destination: tuple[int, ...] = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class HeardFigure:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
events: tuple[tuple[fractions.Fraction, fractions.Fraction, int], ...]
|
||||
operation: str
|
||||
ending: tuple = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SustainedTail:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
pitch: int
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Transformation:
|
||||
id: str
|
||||
parent: str
|
||||
home: str
|
||||
operation: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
events: tuple[str, ...]
|
||||
rhythm: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
|
||||
directions: tuple[int, ...] = ()
|
||||
preserves: tuple[str, ...] = ('rhythm', 'opening_contour')
|
||||
source: str = ''
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class DraftEdit:
|
||||
kind: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
removed: tuple[str, ...]
|
||||
added: tuple[str, ...]
|
||||
source: str = ''
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class IdentifyingFeature:
|
||||
id: str
|
||||
events: tuple[str, ...]
|
||||
rhythm: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
|
||||
intervals: tuple[int, ...]
|
||||
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)
|
||||
class RouteCandidate:
|
||||
id: str
|
||||
notes: tuple[Note, ...]
|
||||
destination: Note
|
||||
spans: tuple[tuple[fractions.Fraction, fractions.Fraction, str], ...]
|
||||
relationship: str
|
||||
sources: tuple[str, ...]
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class EnclosureContract:
|
||||
"""A complete, timed upper/lower approach to a previously chosen arrival.
|
||||
@@ -260,123 +113,6 @@ class EnclosureContract:
|
||||
protected_tail: SustainedTail
|
||||
approach_start: fractions.Fraction | None = None
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MelodicDraft:
|
||||
id: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
notes: tuple[Note, ...]
|
||||
transformations: tuple[Transformation, ...]
|
||||
structural: tuple[str, ...]
|
||||
protected: tuple[str, ...]
|
||||
anchors: tuple[Anchor, ...]
|
||||
home: RealizedMotif | None
|
||||
relationship: str
|
||||
joins: tuple[fractions.Fraction, ...] = ()
|
||||
features: tuple[IdentifyingFeature, ...] = ()
|
||||
reference: str = ''
|
||||
reference_motif: Motif | RealizedMotif | None = None
|
||||
lead_identity: LeadIdentity | None = None
|
||||
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
|
||||
sustained_tails: tuple[SustainedTail, ...] = ()
|
||||
enclosures: tuple[EnclosureContract, ...] = ()
|
||||
expressions: tuple[ExpressionContract, ...] = ()
|
||||
def rests(self):
|
||||
cursor, result = self.start, []
|
||||
for note in self.notes:
|
||||
if note.start > cursor:
|
||||
result.append((cursor, note.start))
|
||||
cursor = max(cursor, note.end)
|
||||
if cursor < self.end:
|
||||
result.append((cursor, self.end))
|
||||
return tuple(result)
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MelodicPassagePlan:
|
||||
id: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
phrase_ids: tuple[str, ...]
|
||||
seed: str
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Landmark:
|
||||
earliest: fractions.Fraction
|
||||
latest: fractions.Fraction
|
||||
low: int
|
||||
high: int
|
||||
direction: int = 0
|
||||
name: str = 'arrival'
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class PhrasePlan:
|
||||
id: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
motif: Motif
|
||||
relationship: str
|
||||
treatment: str
|
||||
target: TargetEvent | None
|
||||
register: tuple[int, int]
|
||||
activity: fractions.Fraction
|
||||
release: fractions.Fraction = fractions.Fraction(1, 2)
|
||||
passages: tuple[ActivitySpan, ...] = ()
|
||||
landmarks: tuple[Landmark, ...] = ()
|
||||
ending: str = 'breath'
|
||||
closure: str = 'closed'
|
||||
seed: str = ''
|
||||
passage_id: str = ''
|
||||
passage_start: fractions.Fraction | None = None
|
||||
passage_end: fractions.Fraction | None = None
|
||||
anchor_options: tuple[tuple[Anchor, ...], ...] = ()
|
||||
gestures: tuple[Gesture, ...] = ()
|
||||
treatments: tuple[HarmonicTreatment, ...] = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Decision:
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
source: str
|
||||
treatment: str
|
||||
notes: tuple[Note, ...]
|
||||
fidelity: float = 0
|
||||
gesture_id: str = ''
|
||||
parent_motif: str = ''
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Phrase:
|
||||
plan: PhrasePlan
|
||||
decisions: tuple[Decision, ...]
|
||||
score: float = 0
|
||||
state: ContinuationState | None = None
|
||||
anchors: tuple[Anchor, ...] = ()
|
||||
draft: MelodicDraft | None = None
|
||||
edits: tuple[DraftEdit, ...] = ()
|
||||
initial: MelodicDraft | None = None
|
||||
refined: MelodicDraft | None = None
|
||||
routes: tuple[RouteCandidate, ...] = ()
|
||||
before_enclosure: MelodicDraft | None = None
|
||||
before_rhythm: MelodicDraft | None = None
|
||||
evaluation: object | None = None
|
||||
rhythm_groups: tuple = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Solo:
|
||||
part: str
|
||||
choices: tuple[generator.scales.selection.Choice, ...]
|
||||
phrases: tuple[Phrase, ...]
|
||||
tempo: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
|
||||
passages: tuple[MelodicPassagePlan, ...] = ()
|
||||
profile: generator.solo.profiles.Instrument | None = None
|
||||
def notes(self) -> tuple[Note, ...]:
|
||||
return sounding_notes(n for p in self.phrases for d in p.decisions for n in d.notes)
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def scale_pitch(scale: generator.scales.library.Scale, index: int, octave: int = 4) -> Pitch:
|
||||
"""Index an extended scale, including negative degrees and octave crossings."""
|
||||
@@ -408,3 +144,33 @@ def permitted(choices: tuple[generator.scales.selection.Choice, ...], start: fra
|
||||
pitches &= choice.scale.pitches
|
||||
covered = max(covered, choice.span.end)
|
||||
return pitches if covered >= end else frozenset()
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Phrase:
|
||||
id: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
notes: tuple[Note, ...]
|
||||
purpose: str
|
||||
direction: str
|
||||
ending: str
|
||||
destination: Anchor | None = None
|
||||
expressions: tuple[ExpressionContract, ...] = ()
|
||||
groups: tuple = ()
|
||||
theme: str = ''
|
||||
score: float = 0.
|
||||
evaluation: object = None
|
||||
sources: tuple[str, ...] = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Solo:
|
||||
part: str
|
||||
choices: tuple
|
||||
phrases: tuple[Phrase, ...]
|
||||
tempo: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
|
||||
profile: generator.solo.profiles.Instrument | None = None
|
||||
def notes(self):
|
||||
return sounding_notes(n for p in self.phrases for n in p.notes)
|
||||
|
||||
Reference in New Issue
Block a user