This commit is contained in:
khodowany
2026-10-04 18:48:02 -05:00
parent 68df7292ee
commit 0e68f5364b
58 changed files with 1416 additions and 7567 deletions
+11 -104
View File
@@ -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)])
-55
View File
@@ -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
View File
@@ -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')
+230
View File
@@ -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)
-414
View File
@@ -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}
-204
View File
@@ -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)
+1 -175
View File
@@ -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})
-56
View File
@@ -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)
-129
View File
@@ -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)
-149
View File
@@ -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)
-451
View File
@@ -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
-56
View File
@@ -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)
-253
View File
@@ -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 ()
-220
View File
@@ -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)
+6 -62
View File
@@ -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()
-935
View File
@@ -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]
-79
View File
@@ -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
View File
@@ -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
+62
View File
@@ -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
View File
@@ -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
View File
@@ -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)})
-35
View File
@@ -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)
-92
View File
@@ -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))
-567
View File
@@ -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
View File
@@ -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)