Simplification
This commit is contained in:
+8
-4
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ()
|
||||||
|
|||||||
+371
-98
@@ -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)]
|
||||||
|
span = self.harmony.at(a)
|
||||||
if degree_path:
|
if degree_path:
|
||||||
span = self.harmony.at(a)
|
if not span or not span.scale:
|
||||||
target = self.scale_step(span.scale,notes[-1].pitch.number(),expected) if span and span.scale else None
|
|
||||||
if target is None:
|
|
||||||
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 ()))
|
||||||
span = self.harmony.at(cursor)
|
for landmark in landmarks:
|
||||||
if not span or not span.scale:
|
approach,support = self.approach(landmark,cursor,variant) if landmark else ((),())
|
||||||
cursor = min(arrival,span.end if span else arrival); continue
|
stop = approach[0].start if approach else landmark.note.start if landmark else end
|
||||||
remaining = arrival-cursor
|
target = approach[0] if approach else landmark.note if landmark else None
|
||||||
if group_index==0 and motif and motif.length<=remaining:
|
while cursor<stop:
|
||||||
pattern,length,source = motif.rhythm,motif.length,motif.source
|
span = self.harmony.at(cursor)
|
||||||
mode = purpose if purpose!='extend' else 'develop'
|
if not span or not span.scale:
|
||||||
else:
|
cursor = min(stop,span.end if span else stop); continue
|
||||||
def suitable(value):
|
remaining = stop-cursor
|
||||||
_,pattern,length,purposes = value
|
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))]
|
||||||
fast = any((b-a).denominator not in (1,2) for a,b in pattern)
|
if group_index==0 and choices:
|
||||||
return (length<=remaining and length>=min(remaining,1)
|
motif = self.choose(choices,'theme_rhythm',(start,variant))
|
||||||
and bool(set(purposes)&({'continue','connect','develop','answer','scalar','arpeggio'}|({'entrance'} if group_index==0 else set())))
|
pattern,length,source = motif.rhythm,motif.length,motif.source
|
||||||
and (not fast or fast_groups<generator.vocabulary.data('solo')['subdivision_groups'])
|
mode = purpose if purpose!='extend' else 'develop'
|
||||||
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:
|
|
||||||
source,pattern,length,_ = self.choose(eligible,'rhythm',(start,variant,group_index))
|
|
||||||
else:
|
else:
|
||||||
length = remaining; pattern,source = ((fractions.Fraction(),length),),'broadening'
|
# Select complete rhythmic groups by their musical function, keeping
|
||||||
mode = self.choose(('connect','develop','arpeggio'),'movement',(start,variant,group_index))
|
# ordered timing diversity until pitches and destination are known.
|
||||||
rhythm = tuple((cursor+a,cursor+b) for a,b in pattern)
|
eligible = self.rhythm_candidates(cursor,remaining,bool(target and remaining<=4),
|
||||||
desired = self.contour(len(pattern),direction,mode,motif,cursor)
|
fast_groups<generator.vocabulary.data('solo')['subdivision_groups'])
|
||||||
phase = float((cursor-start)/max(1,end-start))
|
if eligible:
|
||||||
local_center = center+direction*round(5*(1-abs(2*phase-1)))
|
# Families have equal standing; the combinatorial catalog's file
|
||||||
realized = self.route(rhythm,desired,notes[-1] if notes else incoming,local_center,mode,degree_path=mode=='connect')
|
# count never determines how often its material is requested.
|
||||||
if realized is None:
|
families = {}
|
||||||
self.rejections['route'] += 1; return None
|
for value in eligible:
|
||||||
ns,cs = realized
|
family = 'catalog' if value[0] not in self.authored_ids else 'authored'
|
||||||
if not signature_notes and len((*notes,*ns))>=3:
|
families.setdefault(family,[]).append(value)
|
||||||
signature_notes = tuple((*notes,*ns))
|
family = self.choose(tuple(families),'rhythm_family',(start,variant,group_index))
|
||||||
fast_groups += int(any((b-a).denominator not in (1,2) for a,b in pattern))
|
source,pattern,length,_ = self.choose(families[family],'rhythm',(start,variant,group_index))
|
||||||
notes.extend(ns); contracts.extend(cs); sources.append(source)
|
else:
|
||||||
cursor += length; group_index += 1
|
# An explicit moving primitive, never an arbitrary long hold used
|
||||||
anchor = None; ending = 'open'
|
# to fill a leftover span. Tuplets may only enter as whole groups.
|
||||||
if arrival<end:
|
length = min(fractions.Fraction(1,2),remaining)
|
||||||
sounding_end = end-release
|
pattern,source = ((fractions.Fraction(),length),),'eighth_continuation'
|
||||||
previous = notes[-1].pitch.number() if notes else center
|
if not generator.solo.groups.valid(tuple((cursor+a,cursor+b) for a,b in pattern)):
|
||||||
options = self.options(arrival,sounding_end,previous,True)
|
self.rejections['subdivision_remainder'] += 1; return None
|
||||||
if not options:
|
mode = 'connect' if target and remaining<=4 else self.choose(('connect','develop','arpeggio'),'movement',(start,variant,group_index))
|
||||||
self.rejections['arrival'] += 1; return None
|
rhythm = tuple((cursor+a,cursor+b) for a,b in pattern)
|
||||||
def destination_cost(value):
|
previous = notes[-1] if notes else incoming
|
||||||
note,cs = value
|
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))
|
||||||
degrees = dict(self.harmony.at(arrival).roles.degrees).get(note.pitch.number()%12,())
|
desired = self.contour(len(pattern),direction,mode,motif,cursor)
|
||||||
return (abs(note.pitch.number()-previous)+abs(note.pitch.number()-center)/3-int(bool(set(degrees)&{3,7})),note.pitch.number())
|
# Only the group immediately reaching a target must finish its approach.
|
||||||
target,support = sorted(options,key=destination_cost)[variant%min(3,len(options))]
|
# Earlier material sees the future register but can retain its identity.
|
||||||
notes.append(target); contracts.extend(support)
|
next_target = target if cursor+length==stop else None
|
||||||
ending = 'handoff' if end==self.end else 'complete' if release else 'settle'
|
local_center = round((center+target.pitch.number())/2) if target else center
|
||||||
anchor = generator.solo.types.Anchor('arrival:'+str(arrival),arrival,arrival,target.pitch,frozenset({target.pitch.number()%12}),
|
routes = self.routes(rhythm,desired,previous,local_center,mode,degree_path=mode=='connect',target=next_target)
|
||||||
self.harmony.profile.register,sounding_end-arrival,'terminal' if ending in ('complete','handoff') else 'intermediate',
|
if not routes:
|
||||||
self.harmony.at(arrival).chord,False,sounding_end,ending)
|
self.rejections['route'] += 1; return None
|
||||||
if not notes and any(s.scale and s.start<end and s.end>start for s in self.harmony.spans):
|
ns,cs = routes[variant%len(routes)]
|
||||||
|
if not signature_notes and group_index==0 and len(ns)>=3:
|
||||||
|
signature_notes = ns
|
||||||
|
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)
|
||||||
|
rhythm_groups.append(generator.solo.groups.RhythmGroup(cursor,cursor+length,'gesture',rhythm,
|
||||||
|
cursor%1,source+':'+mode))
|
||||||
|
cursor += length; group_index += 1
|
||||||
|
if landmark:
|
||||||
|
if approach and notes and notes[-1].end==approach[0].start and notes[-1].pitch==approach[0].pitch:
|
||||||
|
# Recompose the join as a direct route in another candidate; never
|
||||||
|
# keep an accidental duplicated entrance because the label is valid.
|
||||||
|
self.rejections['duplicated_approach_entrance'] += 1; return None
|
||||||
|
notes.extend(approach); contracts.extend(support)
|
||||||
|
note = landmark.note
|
||||||
|
if notes and not approach and not landmark.contracts and notes[-1].end==note.start and notes[-1].pitch==note.pitch:
|
||||||
|
before = notes[-1]
|
||||||
|
used = {n.event_id for c in contracts for n in (*c.events,c.target)}
|
||||||
|
held = dataclasses.replace(note,start=before.start,event_id=note.event_id+':carried')
|
||||||
|
if before not in signature_notes and before.event_id not in used and self.harmony.evaluate((held,),complete=True).valid:
|
||||||
|
notes.pop(); note=held
|
||||||
|
notes.append(note); contracts.extend(landmark.contracts)
|
||||||
|
rhythm_groups.append(generator.solo.groups.RhythmGroup(
|
||||||
|
approach[0].start if approach else note.start,landmark.resume,'arrival',
|
||||||
|
tuple((n.start,n.end) for n in (*approach,note)),note.start%1,landmark.anchor.effect))
|
||||||
|
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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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
@@ -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))})
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>')
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
|||||||
Reference in New Issue
Block a user