Simplification

This commit is contained in:
khodowany
2026-10-05 17:24:16 -05:00
parent bcaf2f1403
commit db7966a430
12 changed files with 639 additions and 123 deletions
+8 -4
View File
@@ -194,17 +194,21 @@ Scale labels appear above chord symbols at collection changes and system starts.
The solo path is `composer.compose → construction.Builder → phrases.Phrases → placement → validate → notation/export`. Source pitch/rhythm relationships seed one realized idea. The composer coordinates whole rhythmic groups, written-chord arpeggios, complete scale routes, deliberate rests and harmonic arrivals into a complete phrase candidate. It considers at most 24 alternatives per incoming history, retaining four histories at common source-derived horizons (up to eight measures). These are bounded search settings, not a library of 24 licks. Boundaries may remain open; source breaths inform them without forcing a cadence at every bar. Introductory identity and current development remain distinct. The solo path is `composer.compose → construction.Builder → phrases.Phrases → placement → validate → notation/export`. Source pitch/rhythm relationships seed one realized idea. The composer coordinates whole rhythmic groups, written-chord arpeggios, complete scale routes, deliberate rests and harmonic arrivals into a complete phrase candidate. It considers at most 24 alternatives per incoming history, retaining four histories at common source-derived horizons (up to eight measures). These are bounded search settings, not a library of 24 licks. Boundaries may remain open; source breaths inform them without forcing a cadence at every bar. Introductory identity and current development remain distinct.
Rhythm choices are purpose-aware. Arrival values come from authored arrival cells. Fast subdivision groups have a difficulty budget rather than being selected uniformly from every catalog entry. Sequence indexing retains octave crossings. Catalog families contribute representatives, not probabilities proportional to XML file count. Source notes are never changed by generated rhythm transformations. The `directed-phrases-2` constructor chooses the destination pitch, harmonic time, sounding duration and release before the incoming route. It compares direct, on-time, delayed and explicitly contracted anticipated arrivals. Local settling can precede further development; completing arrivals and source-defined handoffs preserve the full selected hold. Adjacent common-tone motion can share a sounding event with an arrival when complete-duration validation allows it. A dominant-to-dominant movement is local settling, not automatic cadential completion.
The complete-phrase evaluator reports continuity, contextual repetition, rhythmic interruption, broad activity feasibility, harmonic exposure, soft register and declared return fidelity. Histories covering the same span use duration-weighted phrase totals. The final report also contains an independent whole-stream diagnostic; it is labeled separately from the selection total. No reference-fitting schema, target-loss term, constructor bonus or optional training dependency remains. This evaluator is an inspectable engineering preference, not a model of listener enjoyment or a guarantee of globally optimal melody. Rhythm retrieval retains ordered attack/rest timing and the trailing breath, rather than reducing patterns to attack counts or Boolean categories. A clear return preserves the introduced rhythm. Development can rephrase that rhythm while keeping selected opening intervals; extension can reopen a provisional ending. An answer responds through its register and continuation rather than merely inverting every interval. These transformations affect generated material only. Source notes remain untouched. Fast subdivision groups retain the existing tempo/difficulty budget. Catalog families contribute alternatives without probabilities proportional to file count.
Up to three complete routes remain available for each realized group. The route leading into an arrival knows its actual destination; scalar displacement uses octave-aware scale indices. Earlier groups can retain thematic character while approaching the planned register. Approaches and enclosures are constructed together with their destination before the preceding body rhythm; a small local refinement pass still supports prepared suspensions and resolving colors. Protected identifying events and all committed arrivals remain intact. Optional stable colors have their own explicitly validated pitch pool, so the ordinary-map filter cannot silently make an approved color unreachable.
The complete-phrase evaluator reports continuity, recent phrase repetition, rhythmic interruption, broad activity feasibility, harmonic exposure, soft register, declared thematic relationship and preparation of planned arrivals. Returns compare ordered intervals and exact relative rhythm; development, answers and extensions compare the selected opening intervals while allowing rhythmic change. Insufficient notes cannot satisfy a multi-note identity. Required holds are distinguished from unplanned broad notes inside moving material, and a breath after an arrival is not treated as a broken run. Repetition evaluates attacks and relative figures separately from sustained duration. Arrival fulfillment is a hard check, in addition to the approach preference. Contributions sum exactly to the selection total. Constructor labels earn no bonus. Histories covering the same span use duration-weighted phrase totals. The final report also contains an independent whole-stream diagnostic; it is labeled separately from the selection total. No reference-fitting schema, target-loss term, constructor bonus or optional training dependency remains. This evaluator is an inspectable engineering preference, not a model of listener enjoyment or a guarantee of globally optimal melody.
Both solo roles allow all six strings at frets 1–12 without opens. Overlapping register preferences never impose octave copies or string quotas. Ordinary notes remain in the mapped collection throughout their full sounding interval. Typed color, approach, enclosure, anticipation and suspension rules validate real targets, preparation and exposure. Tied fragments reconstruct one sounding event; reattacks remain distinct. The 21 scale patterns and authoritative written chord semantics are unchanged. Unsupported written harmony fails rather than silently substituting another chord. Both solo roles allow all six strings at frets 1–12 without opens. Overlapping register preferences never impose octave copies or string quotas. Ordinary notes remain in the mapped collection throughout their full sounding interval. Typed color, approach, enclosure, anticipation and suspension rules validate real targets, preparation and exposure. Tied fragments reconstruct one sounding event; reattacks remain distinct. The 21 scale patterns and authoritative written chord semantics are unchanged. Unsupported written harmony fails rather than silently substituting another chord.
Accompaniment remains source-derived and canonical across iterations, with independently chosen sparse voicings and rhythms. It neither reads nor reacts to solo notes. Source-defined participation masks keep accompaniment off the physical guitar while it solos. Source vocals, electric melody, pickup timing and runtime representation are preserved. Accompaniment remains source-derived and canonical across iterations, with independently chosen sparse voicings and rhythms. It neither reads nor reacts to solo notes. Source-defined participation masks keep accompaniment off the physical guitar while it solos. Source vocals, electric melody, pickup timing and runtime representation are preserved.
Production exports `*-solo1.xml` (acoustic), `*-solo2.xml` (electric), and `manifest.solo`. Optional `python -m tests.solo_compare` accepts references only after completed generation; it cannot affect candidate selection. It preserves annotations and raw encoding and separately applies the confirmed guitar-written octave convention. No exact-output reproduction is promised. Compare whole pitches, onsets, releases, rests and phrase relationships; attack counts alone are insufficient. The previous fitting experiments and `audition/` and `docs/` directories are removed. Production exports `*-solo1.xml` (acoustic), `*-solo2.xml` (electric), and `manifest.solo`. Optional `python -m tests.solo_compare` accepts references only after completed generation; it cannot affect candidate selection. It preserves annotations and raw encoding and separately applies the confirmed guitar-written octave convention. No exact-output reproduction is promised. Compare whole pitches, onsets, releases, rests and phrase relationships; attack counts alone are insufficient. The report also exports four distinct completed opening candidates where available, marking which survived into the full solo and showing its evaluation separately from alternatives. The optional comparison command renders these source-only candidates without rescoring or recomposing them. The previous fitting experiments and `audition/` and `docs/` directories are removed.
Source songs, scales and useful rhythm/sequence catalogs remain in `data/`. Versioned general rules live in `data/composition`; optional references stay in `build/`, and compatibility expectations live in `tests/fixtures`. Keep only the current comparison and one compact baseline under `build/solo_comparison`. Cached catalogs are checksummed derivatives of production data; a clean build recreates them. Legal TAB is a feasibility approximation, not a finger-assignment or ergonomic guarantee. Source songs, scales and useful rhythm/sequence catalogs remain in `data/`. Versioned general rules live in `data/composition`; optional references stay in `build/`, and compatibility expectations live in `tests/fixtures`. Keep the current comparison and compact pre-change baseline under `build/solo_comparison`; staged score-only assets remain unpublished until validation succeeds. Cached catalogs are checksummed derivatives of production data; a clean build recreates them. Legal TAB is a feasibility approximation, not a finger-assignment or ergonomic guarantee.
Current resource and code migration: Current resource and code migration:
+2 -1
View File
@@ -27,6 +27,7 @@ def compose(analysis,measures,part,end,*,scales,sequences,rhythms,initial=None,e
result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'source_melodic_phrases','version':policy.DEFAULT.composition_version, result=types.Solo(part,choices,phrases,analysis.tempo,end,{'construction':'source_melodic_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()}, 'construction_seconds':time.monotonic()-began,**builder.timings,'operation_coverage':{k:dict(v) for k,v in builder.coverage.items()},
'whole_stream_diagnostic':dataclasses.asdict(builder.winner_evaluation), 'whole_stream_diagnostic':dataclasses.asdict(builder.winner_evaluation),
'opening_candidates':builder.opening_candidates,
'frontiers':builder.frontiers,'completed_candidates':[{'rank':i+1,'evaluation':{'selection_total': str(s.total / (end-start)), 'phrases': [dataclasses.asdict(p.evaluation) for p in s.phrases]},'events':[(str(n.start),str(n.end),n.pitch.number()) for n in s.notes]} for i,s in enumerate(builder.completed)],'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start) 'frontiers':builder.frontiers,'completed_candidates':[{'rank':i+1,'evaluation':{'selection_total': str(s.total / (end-start)), 'phrases': [dataclasses.asdict(p.evaluation) for p in s.phrases]},'events':[(str(n.start),str(n.end),n.pitch.number()) for n in s.notes]} for i,s in enumerate(builder.completed)],'rejections':dict(builder.failures),'decisions':builder.decisions,'expressions_enabled':expressions_enabled},profile,start)
validate(result) validate(result)
return result return result
@@ -44,6 +45,6 @@ def validate(solo):
for p in solo.phrases: for p in solo.phrases:
if p.start!=cursor or p.end<=p.start:raise ValueError('phrase coverage gap or overlap') if p.start!=cursor or p.end<=p.start:raise ValueError('phrase coverage gap or overlap')
cursor=p.end 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') valid,error=harmonic.anchors_valid(p.arrivals or ((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 not valid:raise ValueError(error)
if cursor!=solo.end:raise ValueError('incomplete solo duration') if cursor!=solo.end:raise ValueError('incomplete solo duration')
+22 -1
View File
@@ -33,6 +33,7 @@ class Builder:
self.rules = generator.vocabulary.data('solo')['search'] self.rules = generator.vocabulary.data('solo')['search']
self.timings = {}; self.frontiers = []; self.coverage = collections.defaultdict(collections.Counter); self.decisions = [] self.timings = {}; self.frontiers = []; self.coverage = collections.defaultdict(collections.Counter); self.decisions = []
self.failures = self.phrases.rejections self.failures = self.phrases.rejections
self.opening_candidates = []
def candidates(self,start,end,state): def candidates(self,start,end,state):
incoming = state.notes[-1] if state.notes else None incoming = state.notes[-1] if state.notes else None
@@ -51,8 +52,18 @@ class Builder:
def rank(self,state): def rank(self,state):
return state.total,generator.solo.policy.stable_rank(self.seed,tuple((n.start,n.end,n.pitch.number()) for n in state.notes)) return state.total,generator.solo.policy.stable_rank(self.seed,tuple((n.start,n.end,n.pitch.number()) for n in state.notes))
@staticmethod
def character(state):
phrase = state.phrases[-1]
ns = phrase.notes
# Diversity describes the realized phrase, not its constructor label.
return (tuple((n.start-phrase.start,n.end-n.start) for n in ns[:4]),
tuple((b.pitch.number()>a.pitch.number())-(b.pitch.number()<a.pitch.number()) for a,b in zip(ns[:3],ns[1:4])),
tuple((a.earliest%1,a.effect) for a in phrase.arrivals))
def compose(self): def compose(self):
began = time.monotonic(); self.frontiers = []; self.decisions = [] began = time.monotonic(); self.frontiers = []; self.decisions = []
openings = []
states = (State(),); start = self.start states = (State(),); start = self.start
while start<self.end: while start<self.end:
end = self.phrases.horizon(start) end = self.phrases.horizon(start)
@@ -63,15 +74,25 @@ class Builder:
successors.append(State((*state.phrases,phrase),state.home or (introduced if start>=0 and len(introduced)>=3 else ()),total)) successors.append(State((*state.phrases,phrase),state.home or (introduced if start>=0 and len(introduced)>=3 else ()),total))
if not successors: if not successors:
raise SearchExhausted(f'No valid complete phrase at {start}–{end}; failures: {dict(self.failures)}') raise SearchExhausted(f'No valid complete phrase at {start}–{end}; failures: {dict(self.failures)}')
if start>=0 and not openings:
openings = [s.phrases[-1] for s in successors]
successors.sort(key=self.rank) successors.sort(key=self.rank)
states = tuple(generator.solo.sequences.diverse(successors,self.rank, states = tuple(generator.solo.sequences.diverse(successors,self.rank,
lambda s:(s.phrases[-1].purpose,s.phrases[-1].direction,s.phrases[-1].ending),self.rules['histories'])) self.character,self.rules['histories']))
for state in states: for state in states:
self.coverage[state.phrases[-1].purpose]['retained_histories'] += 1 self.coverage[state.phrases[-1].purpose]['retained_histories'] += 1
self.frontiers.append({'start':str(start),'end':str(end),'completed':len(successors),'retained':len(states),'best':str(min(s.total for s in states))}) self.frontiers.append({'start':str(start),'end':str(end),'completed':len(successors),'retained':len(states),'best':str(min(s.total for s in states))})
start = end start = end
self.completed = tuple(sorted(states,key=self.rank)) self.completed = tuple(sorted(states,key=self.rank))
winner = self.completed[0] winner = self.completed[0]
selected_opening = next((p for p in winner.phrases if p.start>=0),None)
if selected_opening is not None:
remaining = [p for p in openings if p.notes!=selected_opening.notes]
examples = (selected_opening,*generator.solo.sequences.diverse(remaining,
lambda p:(p.evaluation.total,p.id),lambda p:self.character(State((p,))),3))
self.opening_candidates = [{'selected_in_final_solo':p is selected_opening,'start':str(p.start),'end':str(p.end),
'purpose':p.purpose,'ending':p.ending,'evaluation':dataclasses.asdict(p.evaluation),
'events':[(str(n.start),str(n.end),n.pitch.number()) for n in p.notes]} for p in examples]
self.timings['phrase_search_seconds'] = time.monotonic()-began self.timings['phrase_search_seconds'] = time.monotonic()-began
self.winner_evaluation = generator.solo.selection.evaluate(winner.notes,self.start,self.end,self.harmony, self.winner_evaluation = generator.solo.selection.evaluate(winner.notes,self.start,self.end,self.harmony,
home=winner.home,contracts=tuple(c for p in winner.phrases for c in p.expressions),center=self.phrases.center) home=winner.home,contracts=tuple(c for p in winner.phrases for c in p.expressions),center=self.phrases.center)
+9
View File
@@ -61,6 +61,15 @@ def signature(notes):
for i,n in enumerate(notes)) for i,n in enumerate(notes))
def descriptor(pattern, duration):
"""Retain ordered attacks, durations and space, including a trailing breath.
Absolute placement belongs to the caller. Equal counts or Boolean labels
do not make two different rhythmic sentences interchangeable.
"""
return (duration, tuple(pattern))
def broaden(pattern, arrival, release): def broaden(pattern, arrival, release):
"""Reopen an editable binary tail; never slice a tuplet or inherit its phase.""" """Reopen an editable binary tail; never slice a tuplet or inherit its phase."""
if (arrival*2).denominator!=1 or (release*2).denominator!=1:return () if (arrival*2).denominator!=1 or (release*2).denominator!=1:return ()
+356 -83
View File
@@ -11,6 +11,7 @@ import generator.solo.expressions
import generator.solo.groups import generator.solo.groups
import generator.solo.policy import generator.solo.policy
import generator.solo.rhythm import generator.solo.rhythm
import generator.solo.sequences
import generator.solo.selection import generator.solo.selection
import generator.solo.types import generator.solo.types
import generator.vocabulary import generator.vocabulary
@@ -54,6 +55,14 @@ def source_ideas(analysis):
return tuple(v for v,_ in sorted(found.values(),key=rank)[:12]) return tuple(v for v,_ in sorted(found.values(),key=rank)[:12])
@dataclasses.dataclass(frozen=True)
class Arrival:
note: generator.solo.types.Note
anchor: generator.solo.types.Anchor
contracts: tuple = ()
resume: fractions.Fraction = fractions.Fraction()
class Phrases: class Phrases:
def __init__(self,analysis,harmony,sequences,rhythms,start,end,seed,enabled): def __init__(self,analysis,harmony,sequences,rhythms,start,end,seed,enabled):
self.analysis,self.harmony = analysis,harmony self.analysis,self.harmony = analysis,harmony
@@ -62,6 +71,7 @@ class Phrases:
self.ideas = source_ideas(analysis) self.ideas = source_ideas(analysis)
library = generator.solo.rhythm.Rhythms(rhythms,analysis.tempo) library = generator.solo.rhythm.Rhythms(rhythms,analysis.tempo)
self.patterns = tuple(library.groups) self.patterns = tuple(library.groups)
self.authored_ids = frozenset(v[0] for v in self.patterns)
self.arrivals = tuple(sorted({(cell.pattern[-1][1]-cell.pattern[-1][0],cell.duration-cell.pattern[-1][1]) self.arrivals = tuple(sorted({(cell.pattern[-1][1]-cell.pattern[-1][0],cell.duration-cell.pattern[-1][1])
for cell in generator.vocabulary.rhythm_cells(analysis.tempo,'arrival') if cell.pattern and cell.pattern[-1][1]-cell.pattern[-1][0]>=1})) for cell in generator.vocabulary.rhythm_cells(analysis.tempo,'arrival') if cell.pattern and cell.pattern[-1][1]-cell.pattern[-1][0]>=1}))
# Catalog families contribute one peer per rhythmic function, not one # Catalog families contribute one peer per rhythmic function, not one
@@ -72,9 +82,14 @@ class Phrases:
for name,pattern,length,purposes in values: for name,pattern,length,purposes in values:
if length not in lengths or not 2<=len(pattern)<=6: if length not in lengths or not 2<=len(pattern)<=6:
continue continue
descriptor = (any(a[1]<b[0] for a,b in zip(pattern,pattern[1:])),any(a%1 for a,b in pattern),len({b-a for a,b in pattern})>1) descriptor = generator.solo.groups.descriptor(pattern,length)
representatives.setdefault(descriptor,(name,pattern,length,purposes)) representatives.setdefault(descriptor,(name,pattern,length,purposes))
self.patterns += tuple(representatives.values()) self.patterns += tuple(representatives.values())
self.patterns = tuple(dict((generator.solo.groups.descriptor(p,l),(name,p,l,roles))
for name,p,l,roles in self.patterns).values())
self.patterns_by_length = {}
for value in self.patterns:
self.patterns_by_length.setdefault(value[2],[]).append(value)
self.center = harmony.profile.reference or 60 self.center = harmony.profile.reference or 60
self.rejections = collections.Counter() self.rejections = collections.Counter()
self.proposals = collections.Counter() self.proposals = collections.Counter()
@@ -118,31 +133,181 @@ class Phrases:
def note(start,end,pitch,role): def note(start,end,pitch,role):
return generator.solo.types.Note(start,end,pitch,role=role,event_id=f'{start}:{end}:{pitch.number()}') return generator.solo.types.Note(start,end,pitch,role=role,event_id=f'{start}:{end}:{pitch.number()}')
@functools.lru_cache(maxsize=16384) @functools.lru_cache(maxsize=4096)
def options(self,start,end,near,structural=False): def event_options(self,start,end,structural,enabled):
"""Validate the immutable harmonic interval once, not at every near pitch."""
result = [] result = []
span = self.harmony.at(start) span = self.harmony.at(start)
if not span or not span.scale: if not span or not span.scale:
return () return ()
for p in self.pitches(start,end): for pitch in self.pitches(start,end):
if abs(p.number()-near)>12: note = self.note(start,end,pitch,'arrival' if structural else 'motion')
continue
note = self.note(start,end,p,'arrival' if structural else 'motion')
if self.harmony.evaluate((note,),complete=True).valid: if self.harmony.evaluate((note,),complete=True).valid:
if not structural or p.number()%12 in self.harmony.members(start,end): if not structural or pitch.number()%12 in self.harmony.members(start,end):
result.append((note,())) result.append((note,()))
if self.enabled and end-start>=1: if enabled and end-start>=1:
for rule in generator.vocabulary.data('harmony')['colors']: for rule in generator.vocabulary.data('harmony')['colors']:
if rule.get('resolve') or not generator.solo.expressions.eligible_color(rule,span,self.harmony): if rule.get('resolve') or not generator.solo.expressions.eligible_color(rule,span,self.harmony):
continue continue
pitch = generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],p.number()) low,high = self.harmony.profile.register
if pitch.number()!=p.number(): for near in range(low,high+12,12):
pitch = generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(span.chord),rule['degree'],rule['alter'],near)
if not self.harmony.profile.permits(pitch.number()):
continue continue
note = self.note(start,end,pitch,'color')
color = generator.solo.types.ExpressionContract(note.event_id+':'+rule['id'],'color',rule['id'],(note,),note,span.chord) color = generator.solo.types.ExpressionContract(note.event_id+':'+rule['id'],'color',rule['id'],(note,),note,span.chord)
if self.harmony.evaluate((note,),expressions=(color,),complete=True).valid: if self.harmony.evaluate((note,),expressions=(color,),complete=True).valid:
result.append((dataclasses.replace(note,role='color'),(color,))) result.append((note,(color,)))
return tuple(dict.fromkeys(result))
def options(self,start,end,near,structural=False):
return tuple(v for v in self.event_options(start,end,structural,self.enabled) if abs(v[0].pitch.number()-near)<=12)
def arrival_options(self,onset,release,harmonic_time,center,effect,resume=None):
"""Choose the sounding target before realizing its incoming gesture."""
region = self.harmony.at(harmonic_time)
if not region or not region.scale or not onset<release:
return ()
result = []
if onset<harmonic_time:
if not self.enabled or region.start!=harmonic_time or not onset<harmonic_time<release:
return ()
# A future member need not be legal in the preceding collection. Only
# the bounded, validated anticipation authorizes that preceding interval.
candidates = [(p,()) for p in self.pitches(harmonic_time,release)
if p.number()%12 in self.harmony.members(harmonic_time,release)]
else:
candidates = [(n.pitch,cs) for n,cs in self.options(onset,release,center,True)]
for pitch,support in candidates:
if abs(pitch.number()-center)>10:
continue
note = self.note(onset,release,pitch,'arrival')
if onset<harmonic_time:
support = (generator.solo.types.ExpressionContract(note.event_id+':early','anticipation',
'early_chord_tone',(note,),note,region.chord,boundary=harmonic_time),)
elif support:
note = support[0].target
if not self.harmony.evaluate((note,),expressions=support,complete=True).valid:
continue
anchor = generator.solo.types.Anchor('arrival:'+str(onset),onset,onset,pitch,
frozenset({pitch.number()%12}),self.harmony.profile.register,release-onset,
'terminal' if effect in ('complete','handoff') else 'intermediate',region.chord,
False,release,effect)
result.append(Arrival(note,anchor,support,release if resume is None else resume))
return tuple(result) return tuple(result)
def cadence(self,time):
current = self.harmony.at(time)
previous = self.harmony.at(time-fractions.Fraction(1,8))
if not current or not previous or not current.scale or not previous.scale or current.chord==previous.chord:
return False
a,b = (generator.solo.expressions.parsed_chord(s.chord) for s in (previous,current))
root = lambda c:int(generator.model.NATURAL[c['root']['letter']]+generator.model.alteration(c['root']['alter']))%12
return (generator.solo.expressions.quality(a)=='dominant' and
generator.solo.expressions.quality(b)!='dominant' and (root(a)-root(b))%12==7)
def destination_choice(self,options,center,domain,key):
# Each eligible harmonic degree has an octave realization close to the
# phrase's chosen hand/register region. Register exploration belongs to
# the phrase choice, not an unrelated octave lottery at every arrival.
members = {}
for value in options:
pc = value.note.pitch.number()%12
rank = (abs(value.note.pitch.number()-center),value.note.pitch.number())
if pc not in members or rank<members[pc][0]:
members[pc] = (rank,value)
return self.choose(tuple(v for _,v in sorted(members.values(),key=lambda v:v[0])),domain,key)
def destinations(self,start,end,center,variant):
"""Small connected alternatives in harmonic time, independent of a body."""
hold,breath = self.choose(self.arrivals,'arrival',(start,variant))
close = end==self.end or self.choose((True,False),'closure',(start,variant))
result = []
if close and end-start>=3:
release = end-breath
base = max(start+1,release-hold)
times = [(base,base)]
# Near a harmonic change compare anticipation, on-time and delayed
# entry, rather than retiming an already selected strict melody.
for span in self.harmony.spans:
if start+2<=span.start<release-1 and abs(span.start-base)<=2:
for offset in (fractions.Fraction(-1,2),fractions.Fraction(),fractions.Fraction(1,2)):
onset = span.start+offset
if start+1<=onset<release-1:
times.append((onset,span.start if offset<0 else onset))
onset,when = self.choose(tuple(dict.fromkeys(times)),'arrival_time',(start,variant))
effect = 'handoff' if end==self.end else 'complete' if any(self.cadence(s.start) for s in self.harmony.spans if onset<=s.start<release) or self.cadence(when) else 'settle'
options = self.arrival_options(onset,release,when,center,effect,end)
if not options:
return None
result.append(self.destination_choice(options,center,'destination',(start,variant)))
limit = result[0].note.start if result else end
regions = [s for i,s in enumerate(self.harmony.spans) if s.scale and i and
self.harmony.spans[i-1].chord!=s.chord and start+3<=s.start<limit-3]
if regions:
# Local repose is optional; it is not closure at every chord/cell.
region = self.choose((*regions,None),'local_settle',(start,variant))
if region:
offset = self.choose((fractions.Fraction(-1,2),fractions.Fraction(),fractions.Fraction(1,2)), 'local_time',(start,variant))
onset = region.start+offset
duration,space = self.choose(self.arrivals,'local_duration',(start,variant))
release = min(region.end,region.start+duration,limit-1)
resume = min(release+space,limit-1)
local_center = round((center+result[0].note.pitch.number())/2) if result else center
options = self.arrival_options(onset,release,region.start if offset<0 else onset,local_center,'settle',resume)
if options:
result.insert(0,self.destination_choice(options,local_center,'local_destination',(start,variant)))
return tuple(result)
@functools.lru_cache(maxsize=128)
def developments(self,motif,purpose):
if not motif:
return ()
if purpose=='return':
return (motif,)
values = []
# The characteristic pitch relationships can survive rhythmic recasting.
# Source holds remain source music; they do not become obligatory holds
# at the start of every generated development.
cursor = fractions.Fraction(); compact = []
for i,(a,b) in enumerate(motif.rhythm):
gap = a-motif.rhythm[i-1][1] if i else a
cursor += min(gap,fractions.Fraction(1,2))
length = min(b-a,fractions.Fraction(1))
compact.append((cursor,cursor+length)); cursor += length
if generator.solo.groups.valid(tuple(compact)):
values.append(Idea(tuple(compact),motif.intervals,motif.source+':rephrase'))
if purpose=='extend':
# Reopen the provisional ending and continue its motion. This is an
# edit to the existing idea, not a finished idea followed by a new one.
a,b = compact[-1]
stop = a+min(b-a,fractions.Fraction(1,2))
rhythm = (*compact[:-1],(a,stop),(stop,stop+fractions.Fraction(1,2)),(stop+fractions.Fraction(1,2),stop+1))
step = 2 if motif.intervals[-1]>=motif.intervals[-2] else -2
values.append(Idea(rhythm,(*motif.intervals,motif.intervals[-1]+step,motif.intervals[-1]+2*step),motif.source+':extend'))
for name,pattern,length,roles in self.patterns:
if (len(pattern)==len(motif.intervals) and 1<=length<=4 and
set(roles)&{'entrance','continue','connect','develop','answer','scalar'}):
values.append(Idea(pattern,motif.intervals,motif.source+':rhythm:'+name))
# A full rhythmic recurrence is still a legitimate candidate, not a duty.
values.append(motif)
return tuple(dict(((v.rhythm,v.intervals),v) for v in values).values())
@functools.lru_cache(maxsize=1024)
def rhythm_candidates(self,cursor,remaining,approaching,fast_allowed):
desired_roles = {'connect','continue','scalar'} if approaching else {'continue','connect','develop','answer','scalar','arpeggio'}
eligible = []
for length,values in sorted(self.patterns_by_length.items()):
if not min(remaining,2)<=length<=remaining:
continue
for value in values:
name,pattern,length,roles = value
fast = any((b-a).denominator not in (1,2) for a,b in pattern)
if (set(roles)&desired_roles and (not fast or fast_allowed) and
generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in pattern))):
eligible.append(value)
return tuple(eligible)
def scale_step(self,scale,number,step): def scale_step(self,scale,number,step):
"""Continue from sounding pitch, never transfer a degree between maps.""" """Continue from sounding pitch, never transfer a degree between maps."""
try: try:
@@ -161,53 +326,94 @@ class Phrases:
step -= 1 if step>0 else -1 step -= 1 if step>0 else -1
return generator.solo.types.scale_pitch(scale,index+step) return generator.solo.types.scale_pitch(scale,index+step)
def route(self,rhythm,contour,incoming,center,mode='develop',*,degree_path=False): def route(self,rhythm,contour,incoming,center,mode='develop',*,degree_path=False,target=None):
"""DP compares completed paths; no single route step is committed alone.""" values = self.routes(rhythm,contour,incoming,center,mode,degree_path=degree_path,target=target,limit=1)
return values[0] if values else None
def routes(self,rhythm,contour,incoming,center,mode='develop',*,degree_path=False,target=None,limit=3):
return self._routes(rhythm,contour,incoming,center,mode,degree_path,target,limit,self.enabled)
@functools.lru_cache(maxsize=1024)
def _routes(self,rhythm,contour,incoming,center,mode,degree_path,target,limit,enabled):
"""Keep complete paths toward a known future event, not one next step."""
if not rhythm: if not rhythm:
return None return ()
initial = incoming.pitch.number() if incoming else center initial = incoming.pitch.number() if incoming else center
if incoming and mode in ('connect','arpeggio'): if incoming and mode in ('connect','arpeggio'):
direction = 1 if contour[-1]>=contour[0] else -1 direction = (1 if target.pitch.number()>=initial else -1) if target else (1 if contour[-1]>=contour[0] else -1)
center = initial+direction*2 center = initial+direction*2
options = self.options(*rhythm[0],center) options = self.options(*rhythm[0],center)
if mode=='arpeggio': if mode=='arpeggio':
options = tuple(v for v in options if v[0].pitch.number()%12 in self.harmony.at(rhythm[0][0]).roles.basic) options = tuple(v for v in options if v[0].pitch.number()%12 in self.harmony.at(rhythm[0][0]).roles.basic)
starts = sorted(options,key=lambda v:(abs(v[0].pitch.number()-center)+max(0,abs(v[0].pitch.number()-initial)-5),v[0].pitch.number()))[:3] starts = sorted(options,key=lambda v:(abs(v[0].pitch.number()-center)+max(0,abs(v[0].pitch.number()-initial)-5),v[0].pitch.number()))[:6]
states = [(0,(n,),c,n.pitch.number()) for n,c in starts] states = [(abs(n.pitch.number()-initial)/4,(n,),c,n.pitch.number()) for n,c in starts]
def finish_distance(note):
if target is None:
return 0
distance = abs(note.pitch.number()-target.pitch.number())
return max(0,distance-2)*2+int(distance==0 and note.end==target.start)*2
for index,(a,b) in enumerate(rhythm[1:],1): for index,(a,b) in enumerate(rhythm[1:],1):
next_states = [] next_states = []
for cost,notes,contracts,origin in states: for cost,notes,contracts,origin in states:
wanted = origin+contour[min(index,len(contour)-1)] wanted = origin+contour[min(index,len(contour)-1)]
expected = contour[min(index,len(contour)-1)]-contour[min(index-1,len(contour)-1)] expected = contour[min(index,len(contour)-1)]-contour[min(index-1,len(contour)-1)]
if degree_path:
span = self.harmony.at(a) span = self.harmony.at(a)
target = self.scale_step(span.scale,notes[-1].pitch.number(),expected) if span and span.scale else None if degree_path:
if target is None: if not span or not span.scale:
continue continue
wanted = target.number() if target is not None:
# Use octave-aware indices when both pitches belong to this map.
# Across maps the target remains an actual sounding pitch.
try:
displacement = generator.solo.types.scale_index(span.scale,target.pitch.number())-generator.solo.types.scale_index(span.scale,notes[-1].pitch.number())
step = max(-2,min(2,round(displacement/(len(rhythm)-index+1))))
except ValueError:
step = 1 if target.pitch.number()>notes[-1].pitch.number() else -1
pitch = self.scale_step(span.scale,notes[-1].pitch.number(),step)
else:
pitch = self.scale_step(span.scale,notes[-1].pitch.number(),expected)
if pitch is None:
continue
wanted = pitch.number()
expected = wanted-notes[-1].pitch.number() expected = wanted-notes[-1].pitch.number()
for note,support in self.options(a,b,wanted): for note,support in self.options(a,b,wanted):
if degree_path and note.pitch.number()!=wanted:
continue
delta = note.pitch.number()-notes[-1].pitch.number() delta = note.pitch.number()-notes[-1].pitch.number()
if mode=='arpeggio' and (note.pitch.number()%12 not in self.harmony.at(a).roles.basic or delta==0): if degree_path:
if target is None and note.pitch.number()!=wanted:
continue
if target is not None:
try:
step = generator.solo.types.scale_index(span.scale,note.pitch.number())-generator.solo.types.scale_index(span.scale,notes[-1].pitch.number())
if abs(step)>2:
continue
except ValueError:
if abs(delta)>3:
continue
if mode=='arpeggio' and (note.pitch.number()%12 not in span.roles.basic or delta==0):
continue continue
error = abs(note.pitch.number()-wanted)+3*int(delta==0 and expected!=0)+max(0,abs(delta)-7)*2 error = abs(note.pitch.number()-wanted)+3*int(delta==0 and expected!=0)+max(0,abs(delta)-7)*2
if b-a>=1 and note.pitch.number()%12 not in self.harmony.at(a).roles.basic and not support: if b-a>=1 and note.pitch.number()%12 not in span.roles.basic and not support:
error += 2 error += 2
next_states.append((cost+error,(*notes,note),(*contracts,*support),origin)) next_states.append((cost+error,(*notes,note),(*contracts,*support),origin))
if not next_states: if not next_states:
return None return ()
states = sorted(next_states,key=lambda s:(s[0],tuple(n.pitch.number() for n in s[1])))[:8] def rank(state):
for _,notes,contracts,_ in sorted(states,key=lambda s:(s[0],tuple(n.pitch.number() for n in s[1]))): # Admissible reachability pressure uses the actual remaining attacks;
# a partial short route is never compared to a complete long route.
distance = max(0,abs(state[1][-1].pitch.number()-target.pitch.number())-3*(len(rhythm)-index)) if target else 0
return state[0]+distance,tuple(n.pitch.number() for n in state[1])
states = generator.solo.sequences.diverse(next_states,rank,
lambda s:(s[1][0].pitch.number(),s[1][-1].pitch.number()),8)
values = []
for cost,notes,contracts,_ in sorted(states,key=lambda s:(s[0]+finish_distance(s[1][-1]),tuple(n.pitch.number() for n in s[1]))):
if self.harmony.evaluate(notes,expressions=contracts,complete=True).valid: if self.harmony.evaluate(notes,expressions=contracts,complete=True).valid:
return notes,contracts values.append((notes,contracts))
return None return tuple(values[:limit])
def contour(self,count,direction,mode,motif,time): def contour(self,count,direction,mode,motif,time):
if mode in ('statement','return','develop','answer') and motif: if mode in ('statement','return','develop','answer') and motif:
values = motif.intervals values = motif.intervals
sign = -1 if mode=='answer' else 1 sign = 1
# Explicit reduction/extension; never cycle a route to fit its rhythm. # Explicit reduction/extension; never cycle a route to fit its rhythm.
return tuple(sign*values[i] if i<len(values) else sign*values[-1]+direction*2*(i-len(values)+1) for i in range(count)) return tuple(sign*values[i] if i<len(values) else sign*values[-1]+direction*2*(i-len(values)+1) for i in range(count))
paths = self.sequences.connecting(count) if self.sequences else () paths = self.sequences.connecting(count) if self.sequences else ()
@@ -223,6 +429,34 @@ class Phrases:
return tuple(direction*i for i in range(count)) return tuple(direction*i for i in range(count))
return tuple(direction*i*(3 if mode=='arpeggio' else 2) for i in range(count)) return tuple(direction*i*(3 if mode=='arpeggio' else 2) for i in range(count))
def approach(self,arrival,start,variant):
"""Compose a complete optional lead-in before choosing the body rhythm."""
if not self.enabled:
return (),()
family = self.choose(('direct','approach','enclosure'),'approach_family',(start,variant,arrival.note.start))
if family=='direct':
return (),()
rules = [r for r in generator.vocabulary.data('lead_ins')['patterns'] if r['kind']==family and
(not r.get('difficulty') or self.analysis.tempo<=generator.vocabulary.data('roles')['difficulty'][r['difficulty']+'_max_bpm'])]
for rule in sorted(rules,key=lambda r:generator.solo.policy.stable_rank(self.seed+'lead_in',(start,variant,r['id']))):
gaps = tuple(map(fractions.Fraction,rule.get('internal_gaps',[])))
cursor = arrival.note.start-sum(map(fractions.Fraction,rule['durations']),fractions.Fraction())-sum(gaps,fractions.Fraction())-fractions.Fraction(rule['gap'])
if cursor<start+1:
continue
ns = []
for i,(step,length) in enumerate(zip(rule['semitones'],rule['durations'])):
pitch = generator.solo.expressions.chromatic(arrival.note.pitch,step)
ns.append(self.note(cursor,cursor+fractions.Fraction(length),pitch,family))
cursor = ns[-1].end+(gaps[i] if i<len(gaps) else 0)
if any(not self.harmony.profile.permits(n.pitch.number()) for n in ns):
continue
early = next((c.boundary for c in arrival.contracts if c.kind=='anticipation'),None)
c = generator.solo.types.ExpressionContract(arrival.note.event_id+':'+rule['id'],family,rule['id'],tuple(ns),
arrival.note,arrival.anchor.chord,boundary=early)
if self.harmony.evaluate((*ns,arrival.note),expressions=(*arrival.contracts,c),complete=True).valid:
return tuple(ns),(c,)
return (),()
def make(self,start,end,incoming,home,history,variant): def make(self,start,end,incoming,home,history,variant):
if not any(s.scale and s.start<end and s.end>start for s in self.harmony.spans): if not any(s.scale and s.start<end and s.end>start for s in self.harmony.spans):
self.proposals['space'] += 1 self.proposals['space'] += 1
@@ -234,86 +468,125 @@ class Phrases:
purpose = 'develop' purpose = 'develop'
self.proposals[purpose] += 1 self.proposals[purpose] += 1
center = self.center+self.choose((-5,0,5),'register',(start,variant)) center = self.center+self.choose((-5,0,5),'register',(start,variant))
if purpose=='answer' and home:
movement = home[-1].pitch.number()-home[0].pitch.number()
center -= 3 if movement>0 else -3 if movement<0 else 0
if incoming: if incoming:
center = max(incoming.pitch.number()-7,min(incoming.pitch.number()+7,center)) center = max(incoming.pitch.number()-7,min(incoming.pitch.number()+7,center))
direction = self.choose((-1,1),'direction',(start,variant)) destinations = self.destinations(start,end,center,variant)
hold,release = self.choose(self.arrivals,'arrival',(start,variant)) if destinations is None:
close = end==self.end or self.choose((True,True,False),'closure',(start,variant)) self.rejections['destination_feasibility'] += 1; return None
arrival = max(start,end-release-hold) if close and end-start>=4 else end ending = destinations[-1].anchor.effect if destinations and destinations[-1].resume==end else 'open'
notes,contracts,sources = [],[],[] terminal = destinations[-1].anchor if ending!='open' else None
developments = self.developments(motif,purpose)
notes,contracts,sources,rhythm_groups = [],[],[],[]
cursor = start cursor = start
if start>=0 and purpose!='return': if start>=0 and purpose!='return':
cursor += self.choose((fractions.Fraction(),fractions.Fraction(1,2),fractions.Fraction(1)),'entrance',(start,variant)) cursor += self.choose((fractions.Fraction(),fractions.Fraction(1,2),fractions.Fraction(1)),'entrance',(start,variant))
signature_notes = (); group_index = 0; fast_groups = 0 signature_notes = (); group_index = 0; fast_groups = 0
while cursor<arrival: landmarks = (*destinations,*((None,) if not destinations or destinations[-1].resume<end else ()))
for landmark in landmarks:
approach,support = self.approach(landmark,cursor,variant) if landmark else ((),())
stop = approach[0].start if approach else landmark.note.start if landmark else end
target = approach[0] if approach else landmark.note if landmark else None
while cursor<stop:
span = self.harmony.at(cursor) span = self.harmony.at(cursor)
if not span or not span.scale: if not span or not span.scale:
cursor = min(arrival,span.end if span else arrival); continue cursor = min(stop,span.end if span else stop); continue
remaining = arrival-cursor remaining = stop-cursor
if group_index==0 and motif and motif.length<=remaining: choices = [v for v in developments if v.length<=remaining and generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in v.rhythm))]
if group_index==0 and choices:
motif = self.choose(choices,'theme_rhythm',(start,variant))
pattern,length,source = motif.rhythm,motif.length,motif.source pattern,length,source = motif.rhythm,motif.length,motif.source
mode = purpose if purpose!='extend' else 'develop' mode = purpose if purpose!='extend' else 'develop'
else: else:
def suitable(value): # Select complete rhythmic groups by their musical function, keeping
_,pattern,length,purposes = value # ordered timing diversity until pitches and destination are known.
fast = any((b-a).denominator not in (1,2) for a,b in pattern) eligible = self.rhythm_candidates(cursor,remaining,bool(target and remaining<=4),
return (length<=remaining and length>=min(remaining,1) fast_groups<generator.vocabulary.data('solo')['subdivision_groups'])
and bool(set(purposes)&({'continue','connect','develop','answer','scalar','arpeggio'}|({'entrance'} if group_index==0 else set())))
and (not fast or fast_groups<generator.vocabulary.data('solo')['subdivision_groups'])
and generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in pattern)))
eligible = [v for v in self.patterns if suitable(v)]
if eligible: if eligible:
source,pattern,length,_ = self.choose(eligible,'rhythm',(start,variant,group_index)) # Families have equal standing; the combinatorial catalog's file
# count never determines how often its material is requested.
families = {}
for value in eligible:
family = 'catalog' if value[0] not in self.authored_ids else 'authored'
families.setdefault(family,[]).append(value)
family = self.choose(tuple(families),'rhythm_family',(start,variant,group_index))
source,pattern,length,_ = self.choose(families[family],'rhythm',(start,variant,group_index))
else: else:
length = remaining; pattern,source = ((fractions.Fraction(),length),),'broadening' # An explicit moving primitive, never an arbitrary long hold used
mode = self.choose(('connect','develop','arpeggio'),'movement',(start,variant,group_index)) # to fill a leftover span. Tuplets may only enter as whole groups.
length = min(fractions.Fraction(1,2),remaining)
pattern,source = ((fractions.Fraction(),length),),'eighth_continuation'
if not generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in pattern)):
self.rejections['subdivision_remainder'] += 1; return None
mode = 'connect' if target and remaining<=4 else self.choose(('connect','develop','arpeggio'),'movement',(start,variant,group_index))
rhythm = tuple((cursor+a,cursor+b) for a,b in pattern) rhythm = tuple((cursor+a,cursor+b) for a,b in pattern)
previous = notes[-1] if notes else incoming
direction = 1 if target and target.pitch.number()> (previous.pitch.number() if previous else center) else -1 if target else self.choose((-1,1),'direction',(start,variant))
desired = self.contour(len(pattern),direction,mode,motif,cursor) desired = self.contour(len(pattern),direction,mode,motif,cursor)
phase = float((cursor-start)/max(1,end-start)) # Only the group immediately reaching a target must finish its approach.
local_center = center+direction*round(5*(1-abs(2*phase-1))) # Earlier material sees the future register but can retain its identity.
realized = self.route(rhythm,desired,notes[-1] if notes else incoming,local_center,mode,degree_path=mode=='connect') next_target = target if cursor+length==stop else None
if realized is None: local_center = round((center+target.pitch.number())/2) if target else center
routes = self.routes(rhythm,desired,previous,local_center,mode,degree_path=mode=='connect',target=next_target)
if not routes:
self.rejections['route'] += 1; return None self.rejections['route'] += 1; return None
ns,cs = realized ns,cs = routes[variant%len(routes)]
if not signature_notes and len((*notes,*ns))>=3: if not signature_notes and group_index==0 and len(ns)>=3:
signature_notes = tuple((*notes,*ns)) signature_notes = ns
fast_groups += int(any((b-a).denominator not in (1,2) for a,b in pattern)) fast_groups += int(any((b-a).denominator not in (1,2) for a,b in pattern))
notes.extend(ns); contracts.extend(cs); sources.append(source) notes.extend(ns); contracts.extend(cs); sources.append(source)
rhythm_groups.append(generator.solo.groups.RhythmGroup(cursor,cursor+length,'gesture',rhythm,
cursor%1,source+':'+mode))
cursor += length; group_index += 1 cursor += length; group_index += 1
anchor = None; ending = 'open' if landmark:
if arrival<end: if approach and notes and notes[-1].end==approach[0].start and notes[-1].pitch==approach[0].pitch:
sounding_end = end-release # Recompose the join as a direct route in another candidate; never
previous = notes[-1].pitch.number() if notes else center # keep an accidental duplicated entrance because the label is valid.
options = self.options(arrival,sounding_end,previous,True) self.rejections['duplicated_approach_entrance'] += 1; return None
if not options: notes.extend(approach); contracts.extend(support)
self.rejections['arrival'] += 1; return None note = landmark.note
def destination_cost(value): if notes and not approach and not landmark.contracts and notes[-1].end==note.start and notes[-1].pitch==note.pitch:
note,cs = value before = notes[-1]
degrees = dict(self.harmony.at(arrival).roles.degrees).get(note.pitch.number()%12,()) used = {n.event_id for c in contracts for n in (*c.events,c.target)}
return (abs(note.pitch.number()-previous)+abs(note.pitch.number()-center)/3-int(bool(set(degrees)&{3,7})),note.pitch.number()) held = dataclasses.replace(note,start=before.start,event_id=note.event_id+':carried')
target,support = sorted(options,key=destination_cost)[variant%min(3,len(options))] if before not in signature_notes and before.event_id not in used and self.harmony.evaluate((held,),complete=True).valid:
notes.append(target); contracts.extend(support) notes.pop(); note=held
ending = 'handoff' if end==self.end else 'complete' if release else 'settle' notes.append(note); contracts.extend(landmark.contracts)
anchor = generator.solo.types.Anchor('arrival:'+str(arrival),arrival,arrival,target.pitch,frozenset({target.pitch.number()%12}), rhythm_groups.append(generator.solo.groups.RhythmGroup(
self.harmony.profile.register,sounding_end-arrival,'terminal' if ending in ('complete','handoff') else 'intermediate', approach[0].start if approach else note.start,landmark.resume,'arrival',
self.harmony.at(arrival).chord,False,sounding_end,ending) tuple((n.start,n.end) for n in (*approach,note)),note.start%1,landmark.anchor.effect))
if not notes and any(s.scale and s.start<end and s.end>start for s in self.harmony.spans): sources.append('destination:'+landmark.anchor.effect)
cursor = landmark.resume
if not notes:
return None return None
if self.enabled and variant%3 and notes: if self.enabled and variant%3:
notes,contracts = self.expression(tuple(notes),tuple(contracts),signature_notes,start,variant) # Only genuinely local refinements remain here. Every committed
# arrival and the treatment's identifying events are protected.
protected = (*signature_notes,*(n for n in notes if any(n.start<=d.note.start and n.end>=d.note.end and n.pitch==d.note.pitch for d in destinations)))
notes,contracts = self.expression(tuple(notes),tuple(contracts),protected,start,variant)
checked = self.harmony.evaluate(notes,expressions=contracts,until=end,complete=True) checked = self.harmony.evaluate(notes,expressions=contracts,until=end,complete=True)
if not checked.valid: if not checked.valid:
self.rejections[checked.error] += 1; return None self.rejections[checked.error] += 1; return None
anchors = tuple(d.anchor for d in destinations)
evaluation = generator.solo.selection.evaluate(notes,start,end,self.harmony,incoming=(incoming,) if incoming else (), evaluation = generator.solo.selection.evaluate(notes,start,end,self.harmony,incoming=(incoming,) if incoming else (),
home=home,purpose=purpose,contracts=contracts,center=self.center) home=home,purpose=purpose,contracts=contracts,center=center,destinations=anchors,history=history,identity=signature_notes)
if not evaluation.valid:
self.rejections['arrival_contract'] += 1; return None
key = generator.solo.policy.stable_rank(self.seed,(start,end,variant))[:12] key = generator.solo.policy.stable_rank(self.seed,(start,end,variant))[:12]
pattern = tuple((n.start-start,n.end-start) for n in notes) pattern = tuple((n.start-start,n.end-start) for n in notes)
try: try:
groups = generator.solo.groups.describe(pattern,origin=start,source=purpose) generator.solo.groups.describe(pattern,origin=start,source=purpose)
except ValueError: except ValueError:
self.rejections['rhythm_group'] += 1; return None self.rejections['rhythm_group'] += 1; return None
phrase = generator.solo.types.Phrase(key,start,end,tuple(notes),purpose,'up' if direction>0 else 'down',ending, # Refine diagnostics against the chosen sounding events. Local expression
anchor,tuple(contracts),groups,'home' if home else key,float(evaluation.total),evaluation,tuple(sources)) # and shared arrival sustains must not leave stale pre-edit rhythm reports.
rhythm_groups = tuple(dataclasses.replace(g,events=tuple((n.start,n.end) for n in notes
if g.start<=n.start<g.end)) for g in rhythm_groups)
direction = 'up' if notes[-1].pitch.number()>notes[0].pitch.number() else 'down' if notes[-1].pitch.number()<notes[0].pitch.number() else 'returning'
phrase = generator.solo.types.Phrase(key,start,end,tuple(notes),purpose,direction,ending,
terminal,tuple(contracts),rhythm_groups,'home' if home else key,float(evaluation.total),evaluation,tuple(sources),anchors,signature_notes)
return phrase,signature_notes return phrase,signature_notes
def expression(self,notes,contracts,protected,start,variant): def expression(self,notes,contracts,protected,start,variant):
+1 -1
View File
@@ -4,7 +4,7 @@ import dataclasses, hashlib
class Policy: class Policy:
long_note: float = 2. long_note: float = 2.
stopping_note: float = 1. stopping_note: float = 1.
composition_version: str = 'melodic-phrases-1' composition_version: str = 'directed-phrases-2'
DEFAULT=Policy() DEFAULT=Policy()
def stable_rank(seed, value): def stable_rank(seed, value):
return hashlib.sha256((seed + repr(value)).encode()).hexdigest() return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
+2 -1
View File
@@ -20,7 +20,8 @@ def structure(solo):
completed=anchor is None or harmonic.anchor_note(anchor,p.notes) is not None 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, 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, '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 [], 'anchors':[{**plain(a),'pitch':a.pitch.label(),'completed':harmonic.anchor_note(a,p.notes) is not None} for a in p.arrivals or ((anchor,) if anchor else ())],
'identifying_events':plain(p.identity),
'ending_completed':completed,'evaluation':plain(p.evaluation),'score':p.score,'expressions':plain(p.expressions)} 'ending_completed':completed,'evaluation':plain(p.evaluation),'score':p.score,'expressions':plain(p.expressions)}
phrases.append(entry) phrases.append(entry)
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}) 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})
+1 -1
View File
@@ -27,6 +27,6 @@ class Rhythms:
pattern=tuple((e.onset,e.onset+e.duration) for e in bar.events if e.sounding) 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(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 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))) descriptor=groups.descriptor(pattern,bar.length)
representatives.setdefault(descriptor,(item.id,pattern,bar.length,('develop','answer'))) representatives.setdefault(descriptor,(item.id,pattern,bar.length,('develop','answer')))
self.catalog[meter]=tuple(representatives.values()) self.catalog[meter]=tuple(representatives.values())
+69 -16
View File
@@ -1,12 +1,17 @@
"""One complete-event evaluator. No learned or constructor-family rewards.""" """One complete-event evaluator, including the declared musical relationships.
The inputs are realized notes and independently planned obligations. Action
counts and constructor-family labels never award musical credit.
"""
import collections import collections
import fractions import fractions
import generator.solo.harmonic
import generator.solo.scoring import generator.solo.scoring
import generator.solo.types import generator.solo.types
VERSION = 'melodic-phrases-1' VERSION = 'directed-phrases-2'
WEIGHTS = {'continuity': 2, 'repetition': 3, 'interruption': 2, 'activity': 2, WEIGHTS = {'continuity': 2, 'repetition': 3, 'interruption': 2, 'activity': 2,
'harmony': 2, 'register': 1, 'identity': 3} 'harmony': 2, 'register': 1, 'identity': 3, 'arrival': 2}
def signature(notes): def signature(notes):
@@ -16,34 +21,82 @@ def signature(notes):
return tuple((n.start-notes[0].start, n.end-n.start, n.pitch.number()-notes[0].pitch.number()) for n in notes) return tuple((n.start-notes[0].start, n.end-n.start, n.pitch.number()-notes[0].pitch.number()) for n in notes)
def evaluate(notes, start, end, harmony, *, incoming=(), home=(), purpose='develop', contracts=(), center=60): def theme_distance(home, sample, purpose):
if not home or purpose not in ('return','develop','answer','extend'):
return fractions.Fraction()
# Returns preserve the whole introduced signature; development preserves
# its selected opening intervals, allowing the rhythm and conclusion to change.
count = len(home) if purpose=='return' else min(3,len(home))
if count<3 or len(sample)<count:
return fractions.Fraction(2)
a,b = signature(home[:count]),signature(sample[:count])
intervals = [(x[i][2]-x[i-1][2]) for x in (a,b) for i in range(1,count)]
left,right = intervals[:count-1],intervals[count-1:]
pitch = sum(min(fractions.Fraction(1),fractions.Fraction(abs(x-y),4)) for x,y in zip(left,right))/(count-1)
rhythm = sum((x[0],x[1])!=(y[0],y[1]) for x,y in zip(a,b))/fractions.Fraction(count) if purpose=='return' else 0
return pitch+rhythm
def figures(notes):
return tuple(signature(notes[i:i+4]) for i in range(max(0,len(notes)-3)))
def evaluate(notes, start, end, harmony, *, incoming=(), home=(), purpose='develop', contracts=(), center=60,
destinations=(), history=(), identity=()):
notes = generator.solo.types.sounding_notes(notes) notes = generator.solo.types.sounding_notes(notes)
duration = end-start duration = end-start
if duration <= 0: if duration <= 0:
raise ValueError('Evaluation requires a positive span') raise ValueError('Evaluation requires a positive span')
checked = harmony.evaluate(notes, expressions=contracts, until=end, complete=True) checked = harmony.evaluate(notes, expressions=contracts, until=end, complete=True)
anchors_valid,_ = generator.solo.harmonic.anchors_valid(destinations,notes,end,complete=True,closure='closed')
sounding = sum((n.end-n.start for n in notes), fractions.Fraction()) sounding = sum((n.end-n.start for n in notes), fractions.Fraction())
pitches = [n.pitch.number() for n in notes] pitches = [n.pitch.number() for n in notes]
joined = [n.pitch.number() for n in incoming[-1:]]+pitches joined = [n.pitch.number() for n in incoming[-1:]]+pitches
intervals = [b-a for a,b in zip(joined, joined[1:])] intervals = [b-a for a,b in zip(joined, joined[1:])]
continuity = sum(max(0, abs(d)-7) for d in intervals)/max(1,len(intervals)) continuity = fractions.Fraction(sum(max(0, abs(d)-7) for d in intervals),max(1,len(intervals)))
oscillation = sum(a == -b == c and a != 0 for a,b,c in zip(intervals,intervals[1:],intervals[2:])) oscillation = sum(a == -b == c and a != 0 for a,b,c in zip(intervals,intervals[1:],intervals[2:]))
figures = collections.Counter(tuple(pitches[i:i+4]) for i in range(max(0,len(pitches)-3))) patterns = collections.Counter(figures(notes))
repetition = (oscillation+sum(a==b for a,b in zip(pitches,pitches[1:]))+sum(max(0,n-2) for n in figures.values()))/max(1,len(notes)) recent = collections.Counter(f for p in history[-3:] for f in figures(generator.solo.types.sounding_notes(p.notes)))
interruption = sum(a.end<b.start and a.end-a.start<1 and b.end-b.start<1 for a,b in zip(notes,notes[1:]))/max(1,len(notes)) # A single clear return is allowed. Repeating the same figure several times
# still costs something; a "return" label is not unlimited immunity.
overuse = sum(max(0,n+recent[f]-2) for f,n in patterns.items())
repetition = fractions.Fraction(oscillation+sum(a==b for a,b in zip(pitches,pitches[1:]))+overuse,max(1,len(notes)))
arrival_ids = {n.event_id for a in destinations if (n:=generator.solo.harmonic.anchor_note(a,notes))}
interruption = fractions.Fraction(sum(a.end<b.start and a.end-a.start<1 and b.end-b.start<1
and a.event_id not in arrival_ids for a,b in zip(notes,notes[1:])),max(1,len(notes)))
density = fractions.Fraction(len(notes)*4,1)/duration density = fractions.Fraction(len(notes)*4,1)/duration
active = any(s.scale and s.start<end and s.end>start for s in harmony.spans) active = any(s.scale and s.start<end and s.end>start for s in harmony.spans)
activity = max(0,density-7)+(2 if active and not notes else 0) activity = max(0,density-7)+(2 if active and not notes else 0)
if notes and sounding<duration/3: if notes and sounding<duration/3:
activity += (duration/3-sounding)/duration activity += (duration/3-sounding)/duration
identity = 0 # Unplanned broad notes in the moving portion have a different function from
if home and purpose == 'return': # a fulfilled arrival. This does not penalize the required held destination.
sample = notes[:len(home)] motion = [n for n in notes if n.event_id not in arrival_ids and n.role!='quote']
identity = 2 if len(sample)!=len(home) else sum((a[0],a[1])!=(b[0],b[1]) or (a[2]>0)!=(b[2]>0) for a,b in zip(signature(home),signature(sample)))/len(home) activity += sum((max(0,n.end-n.start-2) for n in motion),fractions.Fraction())/duration
register = sum(float(n.end-n.start)*max(0,abs(n.pitch.number()-center)-10) for n in notes)/max(1,float(sounding)) identity_cost = theme_distance(home,identity or notes,purpose)
register = sum(( (n.end-n.start)*max(0,abs(n.pitch.number()-center)-10) for n in notes),fractions.Fraction())/max(1,sounding)
arrival_cost = fractions.Fraction()
for anchor in destinations:
target = generator.solo.harmonic.anchor_note(anchor,notes)
if target is None:
arrival_cost += 2
continue
before = [n for n in notes if n.end<=target.start][-3:]
if before:
distances = [abs(n.pitch.number()-target.pitch.number()) for n in before]+[0]
# A contour may turn. What matters here is whether the final approach
# prepares its already selected destination, rather than jumping to it.
arrival_cost += fractions.Fraction(max(0,distances[-2]-2),6)
arrival_cost += fractions.Fraction(sum(max(0,b-a) for a,b in zip(distances,distances[1:])),12)
if before[-1].end<target.start:
supported_gap = any(c.target.event_id==target.event_id and c.events[-1].end==before[-1].end for c in contracts)
if not supported_gap:
arrival_cost += min(1,target.start-before[-1].end)
arrival_cost /= max(1,len(destinations))
values = dict(continuity=continuity,repetition=repetition,interruption=interruption, values = dict(continuity=continuity,repetition=repetition,interruption=interruption,
activity=activity,harmony=checked.exposure,register=register,identity=identity) activity=activity,harmony=checked.exposure,register=register,identity=identity_cost,arrival=arrival_cost)
raw = {k:fractions.Fraction(str(v)).limit_denominator(1000000) for k,v in values.items()} raw = {k:fractions.Fraction(str(v)).limit_denominator(1000000) for k,v in values.items()}
contributions = {k:v*WEIGHTS[k] for k,v in raw.items()} contributions = {k:v*WEIGHTS[k] for k,v in raw.items()}
return generator.solo.scoring.Evaluation(VERSION,checked.valid,raw,dict(WEIGHTS),contributions, return generator.solo.scoring.Evaluation(VERSION,checked.valid and anchors_valid,raw,dict(WEIGHTS),contributions,
sum(contributions.values(),fractions.Fraction()),{'span':str(duration),'attacks':len(notes),'sounding':str(sounding)}) sum(contributions.values(),fractions.Fraction()),{'span':str(duration),'attacks':len(notes),'sounding':str(sounding),
'destinations':len(destinations),'identity_events':len(identity or notes[:len(home)]),'history_phrases':min(3,len(history))})
+2
View File
@@ -162,6 +162,8 @@ class Phrase:
score: float = 0. score: float = 0.
evaluation: object = None evaluation: object = None
sources: tuple[str, ...] = () sources: tuple[str, ...] = ()
arrivals: tuple[Anchor, ...] = ()
identity: tuple[Note, ...] = ()
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Solo: class Solo:
+26
View File
@@ -327,6 +327,23 @@ def audition_pair(actual,expected,role,assets,output,cache,baseline=None):
'listening_status':'Rendered and signal-checked, not subjectively auditioned.'} 'listening_status':'Rendered and signal-checked, not subjectively auditioned.'}
def candidate_clips(candidates,role,tempo,output,cache):
"""Render the already completed source-only alternatives, never reselect."""
from generator import playback
clips=[]
for index,candidate in enumerate(candidates,1):
origin,end=F(candidate['start']),F(candidate['end'])
audio=render_events([(F(a)-origin,F(b)-origin,F(p)) for a,b,p in candidate['events']],end-origin,tempo,output,cache)
peak=.65*max(map(abs,audio),default=0)
if peak>=1:raise ValueError('Candidate excerpt would clip at comparison gain')
name='audio/'+role.replace('/','_')+f'_candidate_{index}.wav'
playback.write_pcm(output/name,(.65*v for v in audio))
selected=candidate['selected_in_final_solo']
clips.append({'file':name,'version':f'Completed opening {index}'+(' — selected in final solo' if selected else ' — alternative'),
'context':'source-only candidate, solo','peak':peak})
return clips
def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None,baseline_dir=None): def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None,baseline_dir=None):
generated_dir,output=Path(generated_dir),Path(output) generated_dir,output=Path(generated_dir),Path(output)
manifest=json.loads((generated_dir/'manifest.solo').read_text()) manifest=json.loads((generated_dir/'manifest.solo').read_text())
@@ -348,6 +365,9 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
if assets:review['audition']=audition_pair(actual,expected,role,assets,output,tone_cache,baseline) if assets:review['audition']=audition_pair(actual,expected,role,assets,output,tone_cache,baseline)
review['production_trace']=item['trace'] review['production_trace']=item['trace']
review['production_phrases']=item.get('phrase_report',{}) review['production_phrases']=item.get('phrase_report',{})
candidates=review['production_phrases'].get('selection_trace',{}).get('opening_candidates',[])
if assets and candidates:
review['audition']['clips']+=candidate_clips(candidates,role,F(review['audition']['tempo']),output,tone_cache)
# Preserve the real role cutoff; reference material beyond it is not trimmed. # Preserve the real role cutoff; reference material beyond it is not trimmed.
phrases=review['production_phrases'].get('phrases',[]) phrases=review['production_phrases'].get('phrases',[])
if phrases: if phrases:
@@ -384,6 +404,12 @@ def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None
for purpose,counts in sorted(funnel.items()): for purpose,counts in sorted(funnel.items()):
rows.append('<tr><td>'+html.escape(purpose)+'</td>'+''.join('<td>'+str(counts.get(k,0))+'</td>' for k in stages)+'</tr>') rows.append('<tr><td>'+html.escape(purpose)+'</td>'+''.join('<td>'+str(counts.get(k,0))+'</td>' for k in stages)+'</tr>')
rows.append('</table></details>') rows.append('</table></details>')
candidates=r['production_phrases'].get('selection_trace',{}).get('opening_candidates',[])
if candidates:
rows.append('<details open><summary>Completed source-only opening candidates</summary><p>All candidates were composed without the references. Selection accounts for later phrases; the final choice need not have the cheapest opening alone.</p><table><tr><th>Candidate</th><th>Final choice</th><th>Purpose</th><th>Score contributions</th></tr>')
for index,c in enumerate(candidates,1):
rows.append('<tr>'+''.join('<td>'+html.escape(str(v))+'</td>' for v in (index,c['selected_in_final_solo'],c['purpose'],c['evaluation']['contributions']))+'</tr>')
rows.append('</table></details>')
for clip in r.get('audition',{}).get('clips',[]): for clip in r.get('audition',{}).get('clips',[]):
rows.append('<p>'+html.escape(clip['version']+' / '+clip['context'])+'<br><audio controls preload="none" src="'+html.escape(clip['file'])+'"></audio></p>') rows.append('<p>'+html.escape(clip['version']+' / '+clip['context'])+'<br><audio controls preload="none" src="'+html.escape(clip['file'])+'"></audio></p>')
rows.append('<details><summary>Exact note differences</summary><table><tr><th>Beat relative to form</th><th>Generated</th><th>Reference</th></tr>') rows.append('<details><summary>Exact note differences</summary><table><tr><th>Beat relative to form</th><th>Generated</th><th>Reference</th></tr>')
+126
View File
@@ -163,3 +163,129 @@ class ExpressionConstruction(unittest.TestCase):
self.assertEqual(cs[0].kind,'anticipation') self.assertEqual(cs[0].kind,'anticipation')
self.assertLess(ns[-1].start,4);self.assertEqual(ns[-1].end,6) self.assertLess(ns[-1].start,4);self.assertEqual(ns[-1].end,6)
self.assertTrue(b.harmony.evaluate(ns,expressions=cs,complete=True).valid) self.assertTrue(b.harmony.evaluate(ns,expressions=cs,complete=True).valid)
class DirectedPhraseContracts(unittest.TestCase):
def test_different_destinations_change_the_complete_incoming_route(self):
b=builder(('CM7',),count=2)
rhythm=tuple((fractions.Fraction(i,2),fractions.Fraction(i+1,2)) for i in range(4))
incoming=fixtures.note(-1,0,'g')
low=fixtures.note(2,4,'c')
high=fixtures.note(2,4,'c',octave=5)
args=(rhythm,(0,1,2,3),incoming,60,'connect')
a=b.phrases.route(*args,degree_path=True,target=low)
z=b.phrases.route(*args,degree_path=True,target=high)
self.assertIsNotNone(a);self.assertIsNotNone(z)
self.assertNotEqual([n.pitch for n in a[0]],[n.pitch for n in z[0]])
self.assertLess(abs(a[0][-1].pitch.number()-low.pitch.number()),abs(z[0][-1].pitch.number()-low.pitch.number()))
def test_anticipated_destination_is_planned_before_any_body_exists(self):
b=builder(('G6','B7'),count=2)
choices=b.phrases.arrival_options(fractions.Fraction(7,2),fractions.Fraction(6),fractions.Fraction(4),64,'settle')
target=next(a for a in choices if a.note.pitch.number()==63)
self.assertEqual(target.note.end,6)
self.assertTrue(b.harmony.evaluate((target.note,),expressions=target.contracts).valid)
self.assertFalse(b.harmony.evaluate((target.note,)).valid)
with patch.object(b.phrases,'choose',return_value='enclosure'):
notes,contracts=b.phrases.approach(target,fractions.Fraction(),1)
self.assertTrue(notes)
self.assertTrue(b.harmony.evaluate((*notes,target.note),expressions=(*target.contracts,*contracts)).valid)
self.assertEqual(contracts[0].boundary,4)
self.assertFalse(b.harmony.evaluate(notes,expressions=(*target.contracts,*contracts)).valid)
def test_development_can_rephrase_source_hold_while_return_preserves_it(self):
from generator.solo.phrases import idea
b=builder(('CM7',),count=2)
original=(fixtures.note(0,1,'c'),fixtures.note(1,2,'e'),fixtures.note(2,6,'d'))
motif=idea(original,'synthetic')
developments=b.phrases.developments(motif,'develop')
self.assertTrue(any(max(y-x for x,y in d.rhythm)<4 for d in developments))
self.assertTrue(all(d.intervals==motif.intervals for d in developments))
self.assertEqual(b.phrases.developments(motif,'return'),(motif,))
extensions=b.phrases.developments(motif,'extend')
reopened=next(v for v in extensions if v.source.endswith(':extend'))
self.assertLess(reopened.rhythm[2][1]-reopened.rhythm[2][0],4)
self.assertGreater(len(reopened.rhythm),len(motif.rhythm))
self.assertEqual(reopened.intervals[:3],motif.intervals)
def test_harmonic_pool_memoization_preserves_complete_candidates(self):
from generator.solo.phrases import Phrases
b=builder(count=2)
cached=b.candidates(fractions.Fraction(),b.end,construction.State())
with patch.object(Phrases,'event_options',Phrases.event_options.__wrapped__):
clean=builder(count=2).candidates(fractions.Fraction(),b.end,construction.State())
self.assertEqual(cached,clean)
def test_colors_include_both_legal_octaves_at_a_midpoint(self):
b=builder(('C7','FM7'),count=2)
options=b.phrases.options(fractions.Fraction(),fractions.Fraction(1),50)
flat_six={n.pitch.number() for n,cs in options if any(c.rule=='dominant_flat_thirteenth' for c in cs)}
self.assertEqual(flat_six,{44,56})
def test_ordered_rhythmic_relationships_survive_retrieval(self):
from generator.solo.groups import descriptor
first=((fractions.Fraction(),fractions.Fraction(1,2)),(fractions.Fraction(1,2),fractions.Fraction(3,2)),(fractions.Fraction(3,2),fractions.Fraction(2)))
second=((fractions.Fraction(),fractions.Fraction(1)),(fractions.Fraction(1),fractions.Fraction(3,2)),(fractions.Fraction(3,2),fractions.Fraction(2)))
self.assertNotEqual(descriptor(first,2),descriptor(second,2))
self.assertNotEqual(descriptor(first,2),descriptor(first,3)) # trailing breath
def test_complete_candidate_contains_planned_local_and_final_arrivals(self):
b=builder(count=4)
candidates=b.candidates(fractions.Fraction(),b.end,construction.State())
self.assertTrue(any(len(p.arrivals)>1 for p,_ in candidates))
for p,_ in candidates:
self.assertTrue(harmonic.anchors_valid(p.arrivals,p.notes,p.end,complete=True,closure='closed')[0])
self.assertEqual(p.evaluation.total,sum(p.evaluation.contributions.values()))
def test_development_and_answer_require_real_identity_evidence(self):
home=(fixtures.note(0,1,'c'),fixtures.note(1,2,'e'),fixtures.note(2,3,'d'))
developed=(fixtures.note(4,fractions.Fraction(9,2),'d'),fixtures.note(fractions.Fraction(9,2),5,'f',alter=1),fixtures.note(5,6,'e'))
unrelated=(fixtures.note(4,5,'d'),fixtures.note(5,6,'c'),fixtures.note(6,7,'g'))
for purpose in ('develop','answer','extend'):
self.assertEqual(evaluation.theme_distance(home,developed,purpose),0)
self.assertGreater(evaluation.theme_distance(home,unrelated,purpose),0)
self.assertGreater(evaluation.theme_distance(home,developed[:1],purpose),0)
self.assertGreater(evaluation.theme_distance(home,developed,'return'),0)
def test_arrival_preparation_ranks_realized_motion_not_action_label(self):
b=builder(('CM7',),count=1)
target=fixtures.note(2,4,'e',event_id='destination')
anchor=types.Anchor('end',2,2,target.pitch,frozenset({4}),(41,76),2,'terminal','CM7',hold_until=4)
flowing=(fixtures.note(0,1,'c'),fixtures.note(1,2,'d'),target)
jumping=(fixtures.note(0,1,'c'),fixtures.note(1,2,'d',octave=3),target)
good=evaluation.evaluate(flowing,0,4,b.harmony,destinations=(anchor,))
poor=evaluation.evaluate(jumping,0,4,b.harmony,destinations=(anchor,))
self.assertLess(good.raw['arrival'],poor.raw['arrival'])
self.assertLess(good.total,poor.total)
self.assertEqual(good,evaluation.evaluate(flowing,0,4,b.harmony,destinations=(anchor,),purpose='arpeggio'))
def test_history_cost_distinguishes_attacks_from_one_sustain(self):
b=builder(('CM7',),count=4)
notes=tuple(fixtures.note(i,i+1,s) for i,s in enumerate(('c','e','d','g')))
prior=types.Phrase('old',0,4,notes,'statement','up','open')
current=tuple(dataclasses.replace(n,start=n.start+4,end=n.end+4) for n in notes)
fresh=evaluation.evaluate(current,4,8,b.harmony)
repeated=evaluation.evaluate(current,4,8,b.harmony,history=(prior,prior,prior))
self.assertGreater(repeated.raw['repetition'],fresh.raw['repetition'])
held=fixtures.note(4,8,'e')
single=evaluation.evaluate((held,),4,8,b.harmony,history=(prior,prior,prior))
self.assertEqual(single.raw['repetition'],0)
def test_held_destination_is_not_inactive_motion_and_is_binding(self):
b=builder(('CM7',),count=1)
target=fixtures.note(0,4,'e',event_id='held')
anchor=types.Anchor('end',0,0,target.pitch,frozenset({4}),(41,76),4,'terminal','CM7',hold_until=4)
planned=evaluation.evaluate((target,),0,4,b.harmony,destinations=(anchor,))
accidental=evaluation.evaluate((target,),0,4,b.harmony)
self.assertEqual(planned.raw['activity'],0)
self.assertGreater(accidental.raw['activity'],planned.raw['activity'])
self.assertFalse(evaluation.evaluate((dataclasses.replace(target,end=1),),0,4,b.harmony,destinations=(anchor,)).valid)
def test_configured_weights_change_the_authoritative_contribution(self):
b=builder(('CM7',),count=1)
ns=(fixtures.note(0,1,'c'),fixtures.note(1,2,'c'),fixtures.note(2,4,'e'))
before=evaluation.evaluate(ns,0,4,b.harmony)
with patch.dict(evaluation.WEIGHTS,{'repetition':evaluation.WEIGHTS['repetition']+1}):
after=evaluation.evaluate(ns,0,4,b.harmony)
self.assertEqual(after.total-before.total,before.raw['repetition'])
self.assertEqual(after.total,sum(after.contributions.values()))