Solo
This commit is contained in:
@@ -16,6 +16,8 @@ class Analysis:
|
||||
boundaries: tuple[fractions.Fraction, ...]
|
||||
tempo: fractions.Fraction
|
||||
melody: tuple[generator.solo.types.Note, ...]
|
||||
body_offset: fractions.Fraction = fractions.Fraction()
|
||||
source_phrases: tuple[generator.solo.types.SourcePhrase, ...] = ()
|
||||
|
||||
def identity(analysis, measures, part):
|
||||
anchor = analysis.melody[0].pitch.number() if analysis.melody else 0
|
||||
@@ -63,16 +65,14 @@ def analyze(document: dict) -> Analysis:
|
||||
candidates = []
|
||||
for index, measure in enumerate(measures):
|
||||
start = boundaries[index]
|
||||
# One- and two-bar ideas retain leading silence and the complete held ending.
|
||||
for count in (1, 2):
|
||||
# Legacy motif consumers also receive complete longer source material.
|
||||
for count in (1, 2, 4, 6, 8):
|
||||
if index + count >= len(boundaries):
|
||||
continue
|
||||
end = boundaries[index + count]
|
||||
notes = tuple(n for n in melody if start <= n.start < end)
|
||||
if not notes or any(n.end > end for n in notes) or any(a.end > b.start for a, b in zip(notes, notes[1:])):
|
||||
continue
|
||||
if len(notes) > 8:
|
||||
continue
|
||||
normalized = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in notes)
|
||||
identity = repr(tuple((n.start, n.end, n.pitch) for n in normalized)).encode('ascii')
|
||||
identifier = 'head_' + hashlib.sha256(identity).hexdigest()[:12]
|
||||
@@ -82,4 +82,56 @@ def analyze(document: dict) -> Analysis:
|
||||
# Preserve the opening idea, then favor a contrasting rhythm/contour with space.
|
||||
ordered = list(unique.values())
|
||||
ordered.sort(key=lambda m: (next(i for i, n in enumerate(melody) if n.sources[0] == m.notes[0].sources[0]), m.duration))
|
||||
return Analysis(tuple(ordered), tuple(boundaries), tempo, melody)
|
||||
offset = boundaries[generator.score.body_start_measure_index(measures)]
|
||||
phrases = source_phrases(melody, offset, boundaries[-1] - offset,
|
||||
generator.model.fraction(measures[-1]['duration']))
|
||||
return Analysis(tuple(ordered), tuple(boundaries), tempo, melody, offset, phrases)
|
||||
|
||||
def source_phrases(melody, offset, end, bar):
|
||||
"""Partition at musical releases, without slicing a sounding event at bars."""
|
||||
notes = tuple(dataclasses.replace(n, start=n.start-offset, end=n.end-offset,
|
||||
event_id=n.sources[0]) for n in melody if n.end > offset)
|
||||
if any(a.end > b.start for a, b in zip(notes, notes[1:])):
|
||||
raise ValueError('solo source requires a monophonic melody')
|
||||
candidates = sorted({fractions.Fraction(), end, *(n.end for n in notes if 0 < n.end <= end),
|
||||
*(n.start for n in notes if 0 < n.start < end)})
|
||||
candidates = [t for t in candidates if not any(n.start < t < n.end for n in notes)]
|
||||
if not candidates or candidates[0] != 0:
|
||||
candidates.insert(0, fractions.Fraction())
|
||||
best = {end: (0., ())}
|
||||
for start in reversed(candidates[:-1]):
|
||||
options = []
|
||||
for finish in candidates:
|
||||
duration = finish-start
|
||||
if finish not in best or duration <= 0 or duration > 8*bar or duration < 2*bar and finish != end:
|
||||
continue
|
||||
prior = next((n for n in reversed(notes) if n.end <= finish), None)
|
||||
after = next((n for n in notes if n.start >= finish), None)
|
||||
gap = float(max(0, after.start-prior.end)) if prior and after else 0.
|
||||
held = float(prior.end-prior.start) if prior else 0.
|
||||
cost = abs(float(duration/bar)-4)/4 + .7*(held < 2) - .3*min(2,gap)
|
||||
options.append((cost+best[finish][0], (finish,*best[finish][1])))
|
||||
if options:
|
||||
best[start] = min(options)
|
||||
if 0 not in best:
|
||||
# A single extended held event is one musical unit, regardless of length.
|
||||
stops = (end,)
|
||||
else:
|
||||
stops = best[0][1]
|
||||
result, start = [], fractions.Fraction()
|
||||
for finish in stops:
|
||||
actual = tuple(n for n in notes if n.start < finish and n.end > start)
|
||||
cursor, rests = start, []
|
||||
for n in actual:
|
||||
if cursor < n.start:
|
||||
rests.append((cursor,n.start))
|
||||
cursor = n.end
|
||||
if cursor < finish:
|
||||
rests.append((cursor,finish))
|
||||
origin = actual[0].pitch.number() if actual else 0
|
||||
shape = tuple((n.start-start,n.end-start,n.pitch.number()-origin) for n in actual)
|
||||
family = hashlib.sha256(repr(shape).encode()).hexdigest()[:12]
|
||||
pickup = tuple(dataclasses.replace(n,start=n.start-offset,end=n.end-offset,event_id=n.sources[0]) for n in melody if n.end<=offset) if start==0 else ()
|
||||
result.append(generator.solo.types.SourcePhrase(f'source_{start}_{finish}',start,finish,actual,tuple(rests),family,pickup))
|
||||
start = finish
|
||||
return tuple(result)
|
||||
|
||||
@@ -93,7 +93,8 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
|
||||
head_first, head_count, _, _ = sections[2]
|
||||
head = parts['electric']['staves'][0]['measures'][head_first:head_first + head_count]
|
||||
reports = []
|
||||
for section_index, part, other in ((3, 'acoustic', 'electric'), (4, 'electric', 'acoustic')):
|
||||
requests = {}
|
||||
for section_index, part in ((3, 'acoustic'), (4, 'electric')):
|
||||
first, count, offset, length = sections[section_index]
|
||||
initial = generator.accompaniment.carried_chords(parts[part]['staves'][0]['measures'])[first]
|
||||
template = copy.deepcopy(parts[part]['staves'][0]['measures'][first:first + count])
|
||||
@@ -103,7 +104,12 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
|
||||
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(pickup) if n['pitches']]
|
||||
if sounding:
|
||||
cutoff -= generator.model.fraction(pickup['duration']) - min(sounding)
|
||||
solo = generator.solo.composer.compose(analysis, template, part, cutoff, scales=scales, sequences=sequences, rhythms=rhythms, initial=initial, revise=revise, references=references, stage=stage, context=(contexts or {}).get(part))
|
||||
requests[part] = (template, cutoff, initial)
|
||||
selected = generator.solo.composer.compose_pair(analysis, requests, scales=scales, sequences=sequences, rhythms=rhythms, contexts=contexts, stage=stage)
|
||||
for solo, section_index, other in zip(selected, (3, 4), ('electric', 'acoustic')):
|
||||
part = solo.part
|
||||
first, count, offset, length = sections[section_index]
|
||||
template, cutoff, initial = requests[part]
|
||||
if solos is not None:
|
||||
solos.append(solo)
|
||||
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
|
||||
|
||||
@@ -121,10 +121,10 @@ def capture(root, stage, libraries=None, contexts=None):
|
||||
for phrase in solo.phrases:
|
||||
if not phrase.draft:
|
||||
continue
|
||||
begin = boundaries.index(phrase.plan.start)
|
||||
begin = max(i for i,boundary in enumerate(boundaries) if boundary <= phrase.plan.start)
|
||||
stop = next(i for i, boundary in enumerate(boundaries) if boundary >= phrase.plan.end)
|
||||
view = excerpt(parts[solo.part], first + begin, stop - begin)
|
||||
local = tuple(dataclasses.replace(n, start=n.start - phrase.plan.start, end=n.end - phrase.plan.start, continuation=False if i == 0 else n.continuation) for i, n in enumerate(phrase.draft.notes))
|
||||
local = tuple(dataclasses.replace(n, start=n.start - boundaries[begin], end=n.end - boundaries[begin], continuation=False if i == 0 else n.continuation) for i, n in enumerate(phrase.draft.notes))
|
||||
placed = generator.solo.placement.place(local, solo.tempo)
|
||||
if placed is None:
|
||||
raise ValueError(f'{phrase.plan.id}: draft cannot be placed for independent audition')
|
||||
@@ -143,16 +143,16 @@ def capture(root, stage, libraries=None, contexts=None):
|
||||
if placed is None:
|
||||
raise ValueError(stage_name + ': selected theme cannot be placed')
|
||||
sounded = generator.solo.types.sounding_notes(placed[1])
|
||||
contracts = tuple(dict.fromkeys(t for p in solo.phrases for t in p.state.treatments if t.kind != 'chromatic_approach'))
|
||||
contracts = tuple(dict.fromkeys(t for p in solo.phrases for t in p.state.treatments))
|
||||
checked = generator.solo.harmonic.Harmony(solo.choices).evaluate(sounded, contracts, until=solo.end, complete=True)
|
||||
if not checked.valid:
|
||||
if not checked.valid and stage_name != 'initial':
|
||||
raise ValueError(stage_name + ': ' + checked.error)
|
||||
view = excerpt(parts[solo.part], first, count)
|
||||
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], stage_name + '_' + solo.part)
|
||||
generator.solo.notation.replace_region(view, 0, foreground)
|
||||
generator.model.validate_document(view)
|
||||
generator.fretboard.validate_document(view)
|
||||
stages[stage_name] = {'document': view, 'attacks': len(sounded), 'occupancy': float(sum(n.end - n.start for n in sounded) / solo.end), 'harmonic_exposure': checked.exposure}
|
||||
stages[stage_name] = {'document': view, 'attacks': len(sounded), 'occupancy': float(sum(n.end - n.start for n in sounded) / solo.end), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error}
|
||||
song['solos'][solo.part]['lead_stages'] = stages
|
||||
print(path.stem, solo.part, json.dumps({k: v for k, v in song['solos'][solo.part]['metrics'].items() if k != 'search'}), flush=True)
|
||||
result['songs'][path.stem] = song
|
||||
|
||||
+35
-31
@@ -15,45 +15,49 @@ import generator.solo.policy
|
||||
import generator.solo.realization
|
||||
import generator.solo.types
|
||||
|
||||
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated'):
|
||||
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated', mode='thematic'):
|
||||
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
|
||||
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
|
||||
choices = generator.scales.selection.select(timeline, region, scales)
|
||||
for choice in choices:
|
||||
if choice.span.chord and choice.scale is None:
|
||||
raise ValueError(f'{part} solo, source measure {choice.span.source_measure}, beat {choice.span.beat}, {generator.harmony.symbol(choice.span.chord)}: No exact match within the permitted catalog')
|
||||
plans = generator.solo.planning.plans(analysis, measures, choices, end, part, sequences, lead=bool(references))
|
||||
retained = tuple((plan.start + n.start, plan.start + n.end, {n.pitch.number() % 12}) for plan in plans if plan.treatment in ('quote', 'exact_quote') for n in plan.motif.notes)
|
||||
if retained and not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
|
||||
choices = generator.scales.selection.select(timeline, region, scales, retained=retained)
|
||||
if not all(pc <= generator.solo.types.permitted(choices, start, finish) for start, finish, pc in retained):
|
||||
raise ValueError(f'{part} solo: protected quotation has no exact compatible scale collection')
|
||||
choices = tuple(dataclasses.replace(c, reason=c.reason + '; retained quotation') if any(a < c.span.end and b > c.span.start for a, b, _ in retained) else c for c in choices)
|
||||
from generator.solo import leads
|
||||
context = context if context is not None else generator.solo.realization.SearchContext()
|
||||
context.revise = revise
|
||||
context.references = references
|
||||
context.stage = stage
|
||||
|
||||
def realize(plan, previous):
|
||||
for attempt in range(generator.solo.policy.DEFAULT.replan_attempts + 1):
|
||||
context.batch = attempt
|
||||
try:
|
||||
options = generator.solo.realization.alternatives(plan, measures, choices, sequences, rhythms, analysis.tempo, previous, context)
|
||||
return options
|
||||
except generator.solo.realization.SearchExhausted as error:
|
||||
if attempt == generator.solo.policy.DEFAULT.replan_attempts or plan.treatment in ('quote', 'exact_quote', 'lead'):
|
||||
raise
|
||||
context.diagnostics.setdefault('replanning', []).append({'phrase': plan.id, 'attempt': attempt + 1, 'reason': str(error), 'adjustment': 'next diverse complete rhythm and anchor constructions; contracts unchanged'})
|
||||
|
||||
phrases = choose_phrases(plans, realize, lambda first, second, incoming: generator.solo.realization.revise_pair(first, second, incoming, choices, sequences, analysis.tempo, context))
|
||||
context.diagnostics['policy'] = generator.solo.policy.describe()['fingerprint']
|
||||
context.diagnostics['musical_identity'] = generator.solo.analysis.identity(analysis, measures, part)
|
||||
passage_ids = tuple(dict.fromkeys(p.passage_id for p in plans if p.passage_id))
|
||||
passages = tuple(generator.solo.types.MelodicPassagePlan(identifier, min(p.start for p in plans if p.passage_id == identifier), max(p.end for p in plans if p.passage_id == identifier), tuple(p.id for p in plans if p.passage_id == identifier), next(p.seed for p in plans if p.passage_id == identifier)) for identifier in passage_ids)
|
||||
result = generator.solo.types.Solo(part, choices, tuple(phrases), analysis.tempo, end, context.diagnostics, passages)
|
||||
options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode)
|
||||
result = options[0][1]
|
||||
validate(result)
|
||||
return result
|
||||
|
||||
|
||||
def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic'):
|
||||
"""Select two recognizable interpretations together, before arranging backing."""
|
||||
from generator.solo import leads
|
||||
pools = {}
|
||||
for part, (measures,end,initial) in requests.items():
|
||||
timeline = generator.scales.timeline.read({'measures': measures},initial=initial)
|
||||
choices = generator.scales.selection.select(timeline,generator.scales.timeline.Solo(0,end,part),scales)
|
||||
for c in choices:
|
||||
if c.span.chord and c.scale is None:
|
||||
raise ValueError(f'{part} solo, source measure {c.span.source_measure}: No exact match within the permitted catalog')
|
||||
context = (contexts or {}).get(part) or generator.solo.realization.SearchContext()
|
||||
pools[part] = leads.solo_options(analysis,measures,part,end,choices,sequences,rhythms,context,stage,mode)
|
||||
def theme_key(solo):
|
||||
home=next((p.state.home for p in solo.phrases if p.state and p.state.home),None)
|
||||
if not home or not home.notes:return ()
|
||||
return tuple((n.start-home.notes[0].start,n.end-n.start,n.pitch.number()-home.notes[0].pitch.number()) for n in home.notes)
|
||||
pairs = [(a_cost+e_cost+2*leads.similarity(a,e),a,e) for a_cost,a in pools['acoustic'] for e_cost,e in pools['electric']
|
||||
if mode!='thematic' or not theme_key(a) or theme_key(a)!=theme_key(e)]
|
||||
if not pairs:
|
||||
raise generator.solo.realization.SearchExhausted('Distinct acoustic/electric themes not found within the bounded pair search')
|
||||
_,acoustic,electric = min(pairs,key=lambda v:(v[0],generator.solo.policy.stable_rank('solo-pair',(v[1].notes(),v[2].notes()))))
|
||||
overlap = leads.similarity(acoustic,electric)
|
||||
for solo in (acoustic,electric):
|
||||
solo.diagnostics.update(pair_similarity=overlap,pairs_compared=len(pairs),solo_alternatives=len(pools[solo.part]))
|
||||
validate(solo)
|
||||
return acoustic,electric
|
||||
|
||||
|
||||
def phrase_notes(phrase):
|
||||
notes = tuple(n for d in phrase.decisions for n in d.notes)
|
||||
if notes and notes[0].continuation:
|
||||
@@ -116,8 +120,8 @@ def validate(solo):
|
||||
for note in solo.notes():
|
||||
if note.start < 0 or note.end > solo.end or note.end <= note.start or previous and previous.end > note.start:
|
||||
raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
|
||||
# Harmony.evaluate below owns scale permission and the narrow, verified
|
||||
# chromatic exception; no separate permissive final-validation path.
|
||||
if note.pitch.number() % 12 not in generator.solo.types.permitted(solo.choices, note.start, note.end):
|
||||
raise ValueError(f'{solo.part} solo: note outside mapped collection at {note.start}')
|
||||
if note.position not in generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())):
|
||||
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
|
||||
previous = note
|
||||
|
||||
@@ -385,7 +385,7 @@ def relationship_features(draft, notes):
|
||||
actual = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(group, group[1:]))
|
||||
direction.extend(a != b for a, b in zip(actual, transform.directions))
|
||||
reference = draft.reference_motif if transform.operation in ('statement', 'contrast') else draft.home
|
||||
if reference and not draft.lead_identity and transform.operation not in ('close', 'fragment', 'broaden_ending'):
|
||||
if reference and not draft.lead_identity and not draft.source_phrase and transform.operation not in ('close', 'fragment', 'broaden_ending'):
|
||||
development.append(generator.solo.references.relationship(group, reference))
|
||||
joins = []
|
||||
for time in draft.joins:
|
||||
|
||||
@@ -99,32 +99,6 @@ class Harmony:
|
||||
for treatment in treatments:
|
||||
if treatment.start >= until:
|
||||
continue
|
||||
if treatment.kind == 'chromatic_approach':
|
||||
approach = next((n for n in notes if n.event_id == treatment.event), None)
|
||||
if (approach is None or approach.role == 'quote' or approach.pitch.number() != treatment.pitch
|
||||
or approach.start != treatment.start or approach.end != treatment.end
|
||||
or approach.end - approach.start != fractions.Fraction(1, 2)
|
||||
or approach.start % 1 != fractions.Fraction(1, 2)
|
||||
or treatment.resolution is None or abs(treatment.pitch - treatment.resolution) != 1
|
||||
or treatment.deadline != approach.end or len(self.segments(approach)) != 1):
|
||||
return Result(False, 0, error=f'{treatment.id}: invalid chromatic approach event or timing')
|
||||
span = self.at(approach.start)
|
||||
if not span or not span.scale or treatment.pitch % 12 in span.scale.pitches:
|
||||
return Result(False, 0, error=f'{treatment.id}: approach requires mapped harmony and an out-of-collection pitch')
|
||||
resolution = next((n for n in notes if n.start == approach.end), None)
|
||||
if resolution:
|
||||
destination = self.at(resolution.start)
|
||||
if (resolution.event_id != treatment.resolution_event or resolution.continuation
|
||||
or resolution.pitch.number() != treatment.resolution or not destination or not destination.scale
|
||||
or treatment.resolution % 12 not in destination.roles.basic
|
||||
or treatment.resolution % 12 not in generator.solo.types.permitted(self.choices, resolution.start, resolution.end)):
|
||||
return Result(False, 0, error=f'{treatment.id}: chromatic approach has no immediate chord-tone resolution')
|
||||
elif complete or until > approach.end:
|
||||
return Result(False, 0, error=f'{treatment.id}: unresolved chromatic approach')
|
||||
else:
|
||||
pending.append(treatment)
|
||||
valid_contracts.append(treatment)
|
||||
continue
|
||||
if treatment.kind == 'color':
|
||||
spans = [s for s in self.spans if s.start < treatment.end and s.end > treatment.start]
|
||||
if not spans or any(treatment.pitch % 12 not in s.roles.basic | s.roles.specified for s in spans):
|
||||
@@ -155,8 +129,8 @@ class Harmony:
|
||||
sounding += float(duration)
|
||||
covered = note.start
|
||||
for span, start, end in self.segments(note):
|
||||
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end and (t.kind != 'chromatic_approach' or t.event == note.event_id)), None)
|
||||
if start > covered or not span.scale or number % 12 not in span.scale.pitches and not (contract and contract.kind == 'chromatic_approach'):
|
||||
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None)
|
||||
if start > covered or not span.scale or number % 12 not in span.scale.pitches:
|
||||
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end}: outside mapped collection or missing harmony')
|
||||
covered = end
|
||||
function = self.function(number, span)
|
||||
@@ -176,7 +150,7 @@ class Harmony:
|
||||
for index, note in enumerate(notes):
|
||||
for span, start, end in self.segments(note):
|
||||
contracts = [t for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]
|
||||
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': 'chromatic_approach' if any(t.kind == 'chromatic_approach' for t in contracts) else self.connected_motion(notes, index), 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]})
|
||||
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]})
|
||||
return result
|
||||
|
||||
def anchor_note(anchor, notes, *, until=None):
|
||||
|
||||
@@ -79,12 +79,12 @@ def controls(song, libraries):
|
||||
return result
|
||||
|
||||
def render(root):
|
||||
baseline = json.loads((root / 'baseline/snapshot.json').read_text())
|
||||
baseline = json.loads((root / 'paraphrase_baseline/snapshot.json').read_text())
|
||||
current = json.loads((root / 'elaborated/snapshot.json').read_text())
|
||||
cache, clips = {}, []
|
||||
gain = 0.4 # Shared audition gain; every mixed clip is checked for clipping.
|
||||
manifest = {'implementation': current['implementation'], 'baseline_implementation': baseline['implementation'], 'policy': current['policy'], 'shared_gain': gain, 'clips': clips, 'stage_semantics': 'Initial is the selected plain tune; refined adds complete diatonic runs; elaborated adds validated chromatic approaches. All stages share the same underlying selected tune.'}
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Lead melody comparisons</title><style>body{font:16px system-ui;max-width:1500px;margin:2rem auto;padding:1rem}table{border-collapse:collapse}td,th{padding:.6rem;border-bottom:1px solid #ccc;text-align:left}audio{width:240px}small{display:block}</style><h1>Lead melody comparisons</h1><p>Compare the plain tune, its diatonic runs, and its chromatic approaches. All fixed mixes use the original backing, identical tempo and one shared gain. Solo-only clips remove the backing. Initial/refined exports follow the selected production branches; they are not independent searches.</p><p>These metrics verify behavior, not musical quality. Listen for a recognizable tune, prepared destinations, and runs that belong to the tune. <a href="manifest.json">Diagnostics manifest</a>.</p>']
|
||||
manifest = {'implementation': current['implementation'], 'baseline_implementation': baseline['implementation'], 'policy': current['policy'], 'shared_gain': gain, 'clips': clips, 'stage_semantics': 'Source is the original head, close paraphrase is the preserved previous plain theme, refined is the newly recomposed theme, and elaborated includes optional in-scale runs. The selected production branch supplies both new stages.'}
|
||||
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Lead melody comparisons</title><style>body{font:16px system-ui;max-width:1500px;margin:2rem auto;padding:1rem}table{border-collapse:collapse}td,th{padding:.6rem;border-bottom:1px solid #ccc;text-align:left}audio{width:240px}small{display:block}</style><h1>Lead melody comparisons</h1><p>Compare the original head, the previous close paraphrase, the new solo theme, and its optional in-scale runs. Source controls may contain original harmonic tensions that production explicitly adapts. All fixed mixes use the original backing, identical tempo and one shared gain. Solo-only clips remove the backing. Initial/refined exports follow the selected production branches; they are not independent searches.</p><p>These metrics verify behavior, not musical quality. Listen for a recognizable tune, prepared destinations, and runs that belong to the tune. <a href="manifest.json">Diagnostics manifest</a>.</p>']
|
||||
|
||||
def clip(prefix, foreground, tempo, backing=()):
|
||||
path = root / (prefix + '.wav')
|
||||
@@ -103,13 +103,13 @@ def render(root):
|
||||
raise ValueError(name + ': source changed during comparison')
|
||||
for part, solo in song['solos'].items():
|
||||
other = 'electric' if part == 'acoustic' else 'acoustic'
|
||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part) + '</h2><table><tr><th>Excerpt</th><th>Baseline</th><th>Plain lead</th><th>Diatonic runs</th><th>Chromatic approaches</th></tr>')
|
||||
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part) + '</h2><table><tr><th>Excerpt</th><th>Song source</th><th>Previous close paraphrase</th><th>New theme</th><th>Theme with runs</th></tr>')
|
||||
for window, offset, count in (('opening8', 0, 8), ('closing4', solo['count'] - 4, 4), ('full', 0, solo['count'])):
|
||||
first = solo['first'] + offset
|
||||
backing = tuple(audition.excerpt(old['parts'][p], first, count) for p in (other, 'vocal'))
|
||||
body.append('<tr><th>' + window + '</th>')
|
||||
for stage in ('baseline', 'initial', 'refined', 'elaborated'):
|
||||
foreground = audition.excerpt((old if stage == 'baseline' else song)['parts'][part], first, count) if stage in ('baseline', 'elaborated') else audition.excerpt(solo['lead_stages'][stage]['document'], offset, count)
|
||||
for stage in ('initial', 'close_paraphrase', 'refined', 'elaborated'):
|
||||
foreground = audition.excerpt(song['parts'][part], first, count) if stage=='elaborated' else audition.excerpt(old['solos'][part]['lead_stages']['refined']['document'] if stage=='close_paraphrase' else solo['lead_stages'][stage]['document'], offset, count)
|
||||
prefix = f'{stage}/{name}_{part}_{window}'
|
||||
path = clip(prefix + '_fixed', foreground, F(solo['tempo']), backing)
|
||||
solo_path = clip(prefix + '_solo', foreground, F(solo['tempo']))
|
||||
@@ -119,6 +119,7 @@ def render(root):
|
||||
clip(prefix + '_responsive', foreground, F(solo['tempo']), tuple(audition.excerpt(song['parts'][p], first, count) for p in (other, 'vocal')))
|
||||
body.append('</tr>')
|
||||
body.append('</table>')
|
||||
body.append(f'<p><a href="paraphrase_baseline/{name}_{part}_full_solo.wav">Previous complete production solo</a></p>')
|
||||
prefix = f'elaborated/{name}_{part}'
|
||||
(root / (prefix + '.txt')).write_text(solo['report'])
|
||||
(root / (prefix + '.json')).write_text(json.dumps(solo['structure'], indent=2) + '\n')
|
||||
@@ -141,7 +142,7 @@ def render(root):
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_lead_melody_auditions'))
|
||||
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('build/solo_melody_auditions'))
|
||||
action = parser.add_mutually_exclusive_group(required=True)
|
||||
action.add_argument('--capture', action='store_true')
|
||||
action.add_argument('--render', action='store_true')
|
||||
|
||||
+367
-298
@@ -1,317 +1,386 @@
|
||||
"""Complete paired lead ideas, realized by the existing phrase/output pipeline.
|
||||
"""Source-phrase variations: edit actual music, then realize complete routes.
|
||||
|
||||
The bounded search branches on a whole timed hook or answer. Every pitch in
|
||||
that group is measured from one fixed origin, never from a previously repaired
|
||||
note. The original identity survives harmonic adaptation and passage changes.
|
||||
The external lead references are audition controls, never production seeds.
|
||||
All notes use sounding coordinates and all time uses quarter-note Fractions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import itertools
|
||||
import time
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.solo.drafts as drafts
|
||||
import generator.solo.harmonic as harmonic
|
||||
import generator.solo.policy as policy
|
||||
import generator.solo.types as types
|
||||
import generator.fretboard
|
||||
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
def identities(references, incoming):
|
||||
reference = references[0]
|
||||
low, high = reference.features[0][1:]
|
||||
source = tuple(n for n in reference.notes if low <= n.start < high)
|
||||
rhythm = tuple(n.end - n.start for n in source)
|
||||
if len(rhythm) != 4:
|
||||
raise ValueError('lead reference requires a complete four-attack identifying figure')
|
||||
rhythms = (rhythm, (rhythm[1], rhythm[2], rhythm[0], rhythm[3]), (F(1), F(1, 2), F(3, 2), F(1)))
|
||||
shapes = ((0, 3, 2, 0), (0, -3, -2, 0), (0, 2, 1, -1), (0, 0, 2, 1))
|
||||
result = [types.LeadIdentity(f'{reference.id}:lead:{i}:{j}', durations, degrees, reference.id)
|
||||
for i, durations in enumerate(rhythms) for j, degrees in enumerate(shapes)]
|
||||
if incoming.lead_identity:
|
||||
result = [incoming.lead_identity, *(x for x in result if x != incoming.lead_identity)]
|
||||
return tuple(result[:policy.DEFAULT.lead_identities])
|
||||
|
||||
def timings(start, length, durations, finish, harmony):
|
||||
"""Fit meter on an eighth-note grid; never compress into tiny tuplets."""
|
||||
cursor, result = start, []
|
||||
ideal = [duration * length / sum(durations) * 2 for duration in durations]
|
||||
ticks = [max(1, int(value)) for value in ideal]
|
||||
available = int(length * 2)
|
||||
if available < len(ticks) or length * 2 != available:
|
||||
def signature(notes):
|
||||
return tuple((n.start, n.end, n.pitch.number()) for n in notes)
|
||||
|
||||
|
||||
def shift(notes, octave):
|
||||
return tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave+octave),
|
||||
position=None, role='source', event_id=n.event_id or n.sources[0]) for n in notes)
|
||||
|
||||
|
||||
def available(harmony, start, end, *, members=False):
|
||||
span = harmony.at(start)
|
||||
allowed = types.permitted(harmony.choices, start, end)
|
||||
if members:
|
||||
allowed &= harmony.members(start, end)
|
||||
if not span or not span.scale:
|
||||
return ()
|
||||
while sum(ticks) < available:
|
||||
index = max(range(len(ticks)), key=lambda i: (ideal[i] - ticks[i], -i))
|
||||
ticks[index] += 1
|
||||
while sum(ticks) > available:
|
||||
index = max((i for i in range(len(ticks)) if ticks[i] > 1), key=lambda i: (ticks[i] - ideal[i], -i))
|
||||
ticks[index] -= 1
|
||||
for duration in ticks:
|
||||
end = min(finish, cursor + F(duration, 2))
|
||||
if cursor < end:
|
||||
# An authored rest in the harmony is not filled with a melodic fallback.
|
||||
spans = [s for s in harmony.spans if s.start < end and s.end > cursor]
|
||||
if spans and all(s.scale for s in spans):
|
||||
result.append((cursor, end))
|
||||
cursor = end
|
||||
return tuple(result)
|
||||
return tuple(p for i in range(-35, 28) if 41 <= (p := types.scale_pitch(span.scale, i)).number() <= 76 and p.number()%12 in allowed)
|
||||
|
||||
def group_options(spans, degrees, register, harmony, previous, identifier, *, home=(), terminal=False):
|
||||
"""Enumerate whole paired figures, including non-nearest melodic intervals."""
|
||||
from generator.solo.realization import options
|
||||
from generator.solo.planning import target_pitches
|
||||
if not spans:
|
||||
return ((0., ()),)
|
||||
cache = getattr(harmony, 'lead_groups', None)
|
||||
if cache is None:
|
||||
harmony.lead_groups = cache = {}
|
||||
key = (spans, degrees, register, previous.pitch if previous else None, identifier, tuple(n.pitch for n in home), terminal)
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
domains = []
|
||||
for index, (start, end) in enumerate(spans):
|
||||
available = options(harmony.choices, start, end, register)
|
||||
if end - start >= policy.DEFAULT.long_note:
|
||||
members = harmony.members(start, end)
|
||||
available = tuple(p for p in available if p.number() % 12 in members)
|
||||
if terminal and index == len(spans) - 1:
|
||||
chord = next(c.span.chord for c in harmony.choices if c.span.start <= start < c.span.end)
|
||||
eligible = target_pitches(chord, True) & harmony.members(start, end)
|
||||
available = tuple(p for p in available if p.number() % 12 in eligible)
|
||||
if not available:
|
||||
return ()
|
||||
domains.append(available)
|
||||
collection = harmony.at(spans[0][0]).scale
|
||||
result = []
|
||||
for origin in domains[0]:
|
||||
index = types.scale_index(collection, origin.number())
|
||||
desired = tuple(origin.number() + n.pitch.number() - home[0].pitch.number() for n in home) if home else tuple(types.scale_pitch(collection, index + degree).number() for degree in degrees)
|
||||
desired = desired[:len(spans)]
|
||||
if len(desired) < len(spans):
|
||||
continue
|
||||
narrowed = [(origin,)] + [tuple(sorted(domain, key=lambda p: (abs(p.number() - target), p))[:2]) for domain, target in zip(domains[1:], desired[1:])]
|
||||
for pitches in itertools.product(*narrowed):
|
||||
actual = tuple(b.number() - a.number() for a, b in zip(pitches, pitches[1:]))
|
||||
intended = tuple(b - a for a, b in zip(desired, desired[1:]))
|
||||
# A return must supply evidence of the complete heard figure, not just
|
||||
# its first interval. Harmonic adaptation may alter interval quality.
|
||||
if home and any((a > 0) - (a < 0) != (b > 0) - (b < 0) or abs(a - b) > 2 for a, b in zip(actual, intended)):
|
||||
continue
|
||||
shape = sum(abs(a - b) / 12 + .5 * (((a > 0) - (a < 0)) != ((b > 0) - (b < 0))) for a, b in zip(actual, intended)) / max(1, len(actual))
|
||||
if not home and shape > .45:
|
||||
continue
|
||||
notes = tuple(types.Note(start, end, pitch, (identifier,), 'motif', event_id=f'{identifier}:{start}') for (start, end), pitch in zip(spans, pitches))
|
||||
stopped = sum(end - start >= 1 and p.number() % 12 not in harmony.at(start).roles.basic for (start, end), p in zip(spans, pitches)) / len(notes)
|
||||
connection = min(1, abs(origin.number() - previous.pitch.number()) / 12) if previous else 0
|
||||
result.append((shape + .3 * stopped + .15 * connection, notes))
|
||||
result.sort(key=lambda v: (v[0], tuple(n.pitch for n in v[1])))
|
||||
selected = tuple(result[:policy.DEFAULT.lead_realizations])
|
||||
cache[key] = selected
|
||||
return selected
|
||||
|
||||
def make_phrase(plan, notes, groups, identity, incoming, harmony, tempo, fit):
|
||||
from generator.solo import realization
|
||||
def adapt(notes, harmony):
|
||||
"""Document required harmonic rewrites; never repair a sequence one step at a time."""
|
||||
result, edits = [], []
|
||||
for index, note in enumerate(notes):
|
||||
terminal = index == len(notes)-1
|
||||
choices = available(harmony,note.start,note.end,members=terminal or note.end-note.start >= 2)
|
||||
pieces = [(note.start,note.end,choices)]
|
||||
if not choices:
|
||||
pieces = [(a,b,available(harmony,a,b,members=terminal or b-a >= 2)) for _,a,b in harmony.segments(note)]
|
||||
for j,(start,end,domain) in enumerate(pieces):
|
||||
if not domain:
|
||||
if harmony.at(start) and harmony.at(start).scale:
|
||||
return (), ()
|
||||
edits.append(types.DraftEdit('missing_harmony',start,end,(note.event_id,),(), 'explicit no-chord space'))
|
||||
continue
|
||||
pitch = min(domain,key=lambda p:(abs(p.number()-note.pitch.number()),p.number()))
|
||||
identifier = note.event_id if j == 0 else note.event_id+f':harmonic:{j}'
|
||||
actual = dataclasses.replace(note,start=start,end=end,pitch=pitch,event_id=identifier)
|
||||
result.append(actual)
|
||||
if (start,end,pitch) != (note.start,note.end,note.pitch):
|
||||
edits.append(types.DraftEdit('harmonic_adaptation',start,end,(note.event_id,),(identifier,),
|
||||
f'{note.pitch.label()} to {pitch.label()}: sounding duration / {harmony.at(start).chord}'))
|
||||
if result:
|
||||
last=result[-1]
|
||||
domain=available(harmony,last.start,last.end,members=True)
|
||||
if not domain:
|
||||
return (),()
|
||||
if last.pitch.number()%12 not in harmony.members(last.start,last.end):
|
||||
pitch=min(domain,key=lambda p:(abs(p.number()-last.pitch.number()),p.number()))
|
||||
result[-1]=dataclasses.replace(last,pitch=pitch)
|
||||
edits.append(types.DraftEdit('harmonic_destination',last.start,last.end,(last.event_id,),(last.event_id,),
|
||||
f'{last.pitch.label()} to {pitch.label()}: final sounding arrival before release'))
|
||||
return tuple(result),tuple(edits)
|
||||
|
||||
|
||||
def identifying(notes, source):
|
||||
"""A complete heard figure, chosen by its musical content rather than its index."""
|
||||
if not notes:
|
||||
return None
|
||||
first = next((g for g in groups if g[2] in ('statement', 'recall', 'answer_theme')), groups[0])
|
||||
home_notes = tuple(n for n in notes if first[0] <= n.start < first[1])
|
||||
home = realization.make_identity(first[3], identity.id, home_notes, first[0], first[1], first[2], harmony)
|
||||
transformations, features, pairs = [], [], []
|
||||
parent = incoming.development.id if incoming.development else identity.id
|
||||
for start, end, operation, group_id in groups:
|
||||
actual = tuple(n for n in notes if start <= n.start < end)
|
||||
if not actual:
|
||||
continue
|
||||
directions = tuple((b.pitch.number() > a.pitch.number()) - (b.pitch.number() < a.pitch.number()) for a, b in zip(actual, actual[1:]))
|
||||
transformations.append(types.Transformation(group_id, parent, home.id, operation, start, end, tuple(n.event_id for n in actual), tuple((n.start - start, n.end - start) for n in actual), directions, (), identity.source))
|
||||
parent = group_id
|
||||
if operation in ('statement', 'recall', 'answer_theme', 'repeat_with_harmonic_adaptation') and len(actual) == len(identity.degrees):
|
||||
features.append(types.IdentifyingFeature(group_id + ':hook', tuple(n.event_id for n in actual), tuple((n.start - actual[0].start, n.end - n.start) for n in actual), tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:])), identity.id))
|
||||
if operation == 'repeat_with_harmonic_adaptation':
|
||||
pairs.extend((a.event_id, b.event_id) for a, b in zip(home_notes, actual))
|
||||
anchors = []
|
||||
for note, role in ((notes[0], 'opening'), *((n, 'intermediate') for n in notes[1:-1] if any(n.start == g[0] and g[2] == 'repeat_with_harmonic_adaptation' for g in groups)), (notes[-1], 'terminal')):
|
||||
span = harmony.at(note.start)
|
||||
if note.pitch.number() % 12 not in span.roles.basic:
|
||||
if role != 'terminal':
|
||||
continue
|
||||
return None
|
||||
anchors.append(types.Anchor(note.event_id + ':anchor', note.start, note.start, note.pitch, span.roles.basic, plan.register, note.end - note.start, role, span.chord, hold_until=note.end))
|
||||
last = notes[-1]
|
||||
passages = (types.ActivitySpan(plan.start, last.end, 'active', plan.activity, F('0.925')),)
|
||||
if last.end < plan.end:
|
||||
passages += (types.ActivitySpan(last.end, plan.end, 'space', F(), F()),)
|
||||
motif = types.Motif(home.id, home.notes, home.duration, tuple(s.chord for s in harmony.spans if s.start < first[1] and s.end > first[0]), 'invented paired lead: ' + identity.id)
|
||||
actual_plan = dataclasses.replace(plan, motif=motif, passages=passages, release=plan.end - last.end, landmarks=(), anchor_options=(tuple(anchors),), ending='breath' if last.end < plan.end else 'sustain', closure='closed')
|
||||
draft = types.MelodicDraft(plan.id + ':' + identity.id + ':' + policy.stable_rank(plan.seed, tuple(n.pitch for n in notes))[:10], plan.start, plan.end, notes, tuple(transformations), tuple(a.id.removesuffix(':anchor') for a in anchors), (), tuple(anchors), home, first[2], tuple(g[0] for g in groups[1:]), tuple(features), identity.source, motif, identity, tuple(pairs))
|
||||
phrase = realization.checked_phrase(actual_plan, draft, notes, (), incoming, harmony, tempo)
|
||||
if phrase:
|
||||
state = dataclasses.replace(phrase.state, lead_identity=identity)
|
||||
# Different complete musical constructions compete before any ornament.
|
||||
return dataclasses.replace(phrase, state=state, score=phrase.score + fit, initial=draft, refined=draft)
|
||||
return ()
|
||||
count = min(4,max(1,len(notes)//2))
|
||||
windows = [notes[i:i+count] for i in range(len(notes)-count+1)]
|
||||
def rank(group):
|
||||
intervals = tuple(b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:]))
|
||||
rhythm = {(n.end-n.start) for n in group}
|
||||
# Favor a rhythm/turn with information; leave long holds available for development.
|
||||
return (-len(rhythm),-len(set(intervals)),sum(max(F(),n.end-n.start-1) for n in group),group[0].start)
|
||||
group = min(windows,key=rank)
|
||||
return (types.IdentifyingFeature(source+':figure',tuple(n.event_id for n in group),
|
||||
tuple((n.start-group[0].start,n.end-n.start) for n in group),
|
||||
tuple(b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])),source),)
|
||||
|
||||
def candidates(plan, measures, choices, incoming, tempo, context):
|
||||
from generator.solo import realization, sequences
|
||||
harmony_key = ('lead_harmony', policy.stable_rank('harmony', choices))
|
||||
if harmony_key not in context.resources:
|
||||
context.resources[harmony_key] = harmonic.Harmony(choices)
|
||||
harmony = context.resources[harmony_key]
|
||||
active = [s for s in harmony.spans if s.scale and s.start < plan.end and s.end > plan.start]
|
||||
if not active:
|
||||
silent_plan = dataclasses.replace(plan, passages=(types.ActivitySpan(plan.start, plan.end, 'space', F(), F()),), release=plan.end-plan.start, landmarks=(), anchor_options=())
|
||||
draft = types.MelodicDraft(plan.id + ':space', plan.start, plan.end, (), (), (), (), (), None, 'space')
|
||||
phrase = realization.checked_phrase(silent_plan, draft, (), (), incoming, harmony, tempo)
|
||||
return (dataclasses.replace(phrase, initial=draft, refined=draft),) if phrase else ()
|
||||
finish = min(plan.end, active[-1].end)
|
||||
boundaries, cursor = [], F()
|
||||
for measure in measures:
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
if plan.start <= cursor < finish:
|
||||
boundaries.append((cursor, min(cursor + length, finish)))
|
||||
cursor += length
|
||||
result = []
|
||||
for identity in identities(context.references, incoming):
|
||||
states = [(0., (), (), ())]
|
||||
for index, (start, end) in enumerate(boundaries):
|
||||
final = index == len(boundaries) - 1
|
||||
hook = index in (0, 2) and not final
|
||||
returning = hook and index == 2
|
||||
operation = ('recall' if incoming.lead_identity == identity else 'answer_theme' if incoming.lead_identity else 'statement') if index == 0 else 'repeat_with_harmonic_adaptation' if returning else 'close' if final else 'extend' if index >= 4 else 'answer'
|
||||
# Paired answers retain the hook's direction of departure. Ending
|
||||
# alternatives supply timing and pitches together, not inserted holds.
|
||||
specs = [(identity.durations, identity.degrees)] if hook else [((F(1), F(1, 2), F(1, 2), F(1), F(1)), (0, 1, 2, 1, -1)), ((F(2), F(1, 2), F(1, 2), F(1)), (0, 1, 2, 1))]
|
||||
if index >= 4 and not final:
|
||||
reference = context.references[0]
|
||||
profiles = [d for d in reference.development if d[1] == index * 4 and d[3]]
|
||||
if profiles:
|
||||
_, low, high, degrees = profiles[0]
|
||||
source = tuple(n for n in reference.notes if low <= n.start < high)
|
||||
specs = [(tuple(n.end - n.start for n in source), degrees)]
|
||||
if final:
|
||||
specs = [((F(1), F(1, 2), F(1, 2), F(2)), (0, -1, -2, -1)), ((F(1, 2), F(1, 2), F(1), F(1)), (0, 2, 1, 0))]
|
||||
if end - start < 2:
|
||||
specs = [((end - start,), (0,))]
|
||||
elif end - start < 4:
|
||||
specs = [((F(1, 2), end - start - F(1, 2)), (0, -1)), ((F(1), end - start - 1), (0, 1))]
|
||||
following = []
|
||||
for cost, notes, groups, home in states:
|
||||
for variant, (durations, degrees) in enumerate(specs):
|
||||
release = F(1) if final and variant == 1 and end - start >= 4 else F()
|
||||
spans = timings(start, end - start - release, durations, end - release, harmony)
|
||||
identifier = f'{plan.id}:{identity.id}:{index}:{variant}'
|
||||
previous = notes[-1] if notes else incoming.last
|
||||
heard = home if returning else incoming.home.notes if index == 0 and incoming.home and incoming.lead_identity == identity else ()
|
||||
for local_cost, actual in group_options(spans, degrees, plan.register, harmony, previous, identifier, home=heard, terminal=final):
|
||||
if not actual:
|
||||
following.append((cost, notes, groups, home))
|
||||
continue
|
||||
following.append((cost + local_cost, (*notes, *actual), (*groups, (start, end, operation, identifier)), actual if index == 0 else home))
|
||||
states = sorted(following, key=lambda v: (v[0], tuple(n.pitch for n in v[1])))[:policy.DEFAULT.lead_realizations]
|
||||
context.diagnostics['evaluated_extensions'] += len(following)
|
||||
context.diagnostics['peak_states'] = max(context.diagnostics['peak_states'], len(states))
|
||||
if not states:
|
||||
context.diagnostics.setdefault('lead_failures', []).append({'phrase': plan.id, 'identity': identity.id, 'start': str(start), 'end': str(end), 'operation': operation, 'reason': 'no complete figure satisfies the harmonic, duration and return constraints within the bounded candidate pool'})
|
||||
break
|
||||
for cost, notes, groups, home in states:
|
||||
if not notes:
|
||||
continue
|
||||
phrase = make_phrase(plan, notes, groups, identity, incoming, harmony, tempo, cost / max(1, len(groups)))
|
||||
if phrase:
|
||||
# A heard identity is a real option across passages, not obligatory
|
||||
# perpetual development of whichever fragment happened most recently.
|
||||
if incoming.lead_identity and identity != incoming.lead_identity:
|
||||
phrase = dataclasses.replace(phrase, score=phrase.score + .15)
|
||||
result.append(phrase)
|
||||
context.diagnostics['generated_candidates'] += len(result)
|
||||
return sequences.diverse(result, lambda p: (p.score, policy.stable_rank(plan.seed, p.draft.id)), lambda p: p.draft.lead_identity.id, policy.DEFAULT.phrase_alternatives)
|
||||
|
||||
def approach_pitch(target, number):
|
||||
"""Spell a semitone neighbor using the adjacent diatonic letter."""
|
||||
letters = generator.model.LETTERS
|
||||
step = 1 if number > target.number() else -1
|
||||
index = letters.index(target.letter) + step
|
||||
octave, degree = divmod(index, 7)
|
||||
candidates = []
|
||||
for letter, octave in ((letters[degree], target.octave + octave), (target.letter, target.octave)):
|
||||
natural = generator.model.NATURAL[letter] + 12 * (octave + 1)
|
||||
candidates.append(types.Pitch(letter, F(number - natural), octave))
|
||||
# A natural above A-flat is clearer than B-double-flat; an ordinary
|
||||
# leading tone still uses its adjacent letter (D-sharp into E).
|
||||
return min(candidates, key=lambda p: abs(p.alter))
|
||||
|
||||
def chromatic(phrase, incoming, harmony, tempo):
|
||||
from generator.solo import realization
|
||||
notes = realization.emitted_notes(phrase)
|
||||
protected = {*phrase.draft.protected, *phrase.draft.structural, *(e for f in phrase.draft.features for e in f.events)}
|
||||
budget = min(policy.DEFAULT.chromatic_max_per_eight_bars, max(1, int((phrase.plan.end - phrase.plan.start) / 8)))
|
||||
proposals = []
|
||||
for index, (note, target) in enumerate(zip(notes, notes[1:])):
|
||||
if note.event_id in protected or note.end - note.start != F(1, 2) or note.start % 1 != F(1, 2) or note.end != target.start or len(harmony.segments(note)) != 1:
|
||||
continue
|
||||
span, destination = harmony.at(note.start), harmony.at(target.start)
|
||||
if not span or not span.scale or not destination or target.pitch.number() % 12 not in destination.roles.basic:
|
||||
continue
|
||||
for number in (target.pitch.number() - 1, target.pitch.number() + 1):
|
||||
if number % 12 in span.scale.pitches or not phrase.plan.register[0] <= number <= phrase.plan.register[1]:
|
||||
continue
|
||||
pitch = approach_pitch(target.pitch, number)
|
||||
treatment = types.HarmonicTreatment(note.event_id + ':chromatic', 'chromatic_approach', number, note.start, note.end, target.pitch.number(), target.start, F(), note.event_id, target.event_id)
|
||||
smooth = abs(number - (notes[index - 1].pitch.number() if index else note.pitch.number()))
|
||||
proposals.append((smooth, abs(number - note.pitch.number()), note.start, index, pitch, treatment))
|
||||
chosen_times, used_routes = [], set()
|
||||
previous_times = [t.start for t in incoming.treatments if t.kind == 'chromatic_approach']
|
||||
original_score = phrase.score
|
||||
for _, _, _, index, pitch, treatment in sorted(proposals, key=lambda p: p[:4]):
|
||||
if len(chosen_times) >= budget or any(abs(treatment.start - t) < 8 for t in (*previous_times, *chosen_times)):
|
||||
continue
|
||||
route_ids = {r.id for r in phrase.routes if any(n.event_id == treatment.event for n in r.notes)}
|
||||
if route_ids & used_routes:
|
||||
continue
|
||||
original = realization.emitted_notes(phrase)
|
||||
changed = tuple(dataclasses.replace(n, pitch=pitch, position=None, role='chromatic_approach') if n.event_id == treatment.event else n for n in original)
|
||||
contracts = (*phrase.state.treatments, treatment)
|
||||
if not harmony.evaluate(changed, contracts, complete=True).valid:
|
||||
continue
|
||||
draft = realization.rebase_draft(phrase.draft, changed, harmony)
|
||||
edit = types.DraftEdit('chromatic_approach', treatment.start, treatment.deadline, (treatment.event,), (treatment.event,), 'semitone resolution to ' + treatment.resolution_event)
|
||||
checked = realization.checked_phrase(phrase.plan, draft, changed, contracts, incoming, harmony, tempo, (*phrase.edits, edit))
|
||||
if checked:
|
||||
routes = tuple(dataclasses.replace(r, notes=tuple(dataclasses.replace(n, pitch=pitch, position=None, role='chromatic_approach') if n.event_id == treatment.event else n for n in r.notes), relationship=r.relationship + ('; chromatic approach' if r.id in route_ids else '')) for r in phrase.routes)
|
||||
phrase = dataclasses.replace(checked, score=original_score, initial=phrase.initial, refined=phrase.refined, routes=routes, state=dataclasses.replace(checked.state, lead_identity=phrase.state.lead_identity))
|
||||
chosen_times.append(treatment.start)
|
||||
used_routes.update(route_ids)
|
||||
return phrase
|
||||
|
||||
def alternatives(plan, measures, choices, sequences, tempo, previous, context):
|
||||
from generator.solo import realization
|
||||
incoming = previous if isinstance(previous, types.ContinuationState) else types.ContinuationState('', last=previous)
|
||||
context.resources[id(sequences)] = sequences
|
||||
key = ('paired-lead', plan, incoming, policy.stable_rank('harmony', choices), tuple(generator.model.fraction(m['duration']) for m in measures), tempo, id(sequences), tuple(r.version for r in context.references), policy.describe()['fingerprint'], context.stage)
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits'] += 1
|
||||
return context.results[key]
|
||||
started = time.monotonic()
|
||||
context.diagnostics['searches'] += 1
|
||||
complete = candidates(plan, measures, choices, incoming, tempo, context)
|
||||
if not complete:
|
||||
raise realization.SearchExhausted(f'{plan.id}: no complete paired lead reaches the terminal harmony within {policy.DEFAULT.lead_identities} identities / {policy.DEFAULT.lead_realizations} realizations; not proof of incompatibility')
|
||||
harmony = harmonic.Harmony(choices)
|
||||
result = []
|
||||
for phrase in complete:
|
||||
score, identity = phrase.score, phrase.state.lead_identity
|
||||
if context.stage != 'initial':
|
||||
# The complete plain tune is selected by the same score in all stages.
|
||||
# Ornament quality cannot rescue a weak underlying idea.
|
||||
phrase = realization.elaborate(phrase, incoming, harmony, sequences, tempo, context)
|
||||
diatonic = realization.rebase_draft(phrase.draft, realization.emitted_notes(phrase), harmony)
|
||||
phrase = dataclasses.replace(phrase, refined=diatonic, score=score, state=dataclasses.replace(phrase.state, lead_identity=identity))
|
||||
if context.stage == 'elaborated':
|
||||
phrase = chromatic(phrase, incoming, harmony, tempo)
|
||||
result.append(dataclasses.replace(phrase, score=score))
|
||||
context.diagnostics['construction_seconds'] = context.diagnostics.get('construction_seconds', 0) + time.monotonic() - started
|
||||
context.diagnostics.setdefault('complete_ideas', {})[policy.stable_rank('incoming', (plan.id, incoming))[:16]] = [{'id': p.draft.id, 'identity': p.draft.lead_identity.id if p.draft.lead_identity else 'space', 'score': p.score, 'attacks': len(p.initial.notes), 'return_pairs': len(p.draft.correspondence), 'edits': [e.kind for e in p.edits]} for p in result]
|
||||
context.results[key] = tuple(result)
|
||||
def variations(notes, source, harmony):
|
||||
"""One purposeful timing or pitch edit; no generic replacement openings."""
|
||||
result=[]
|
||||
if source.pickup and notes and source.start==0:
|
||||
length=-source.pickup[0].start
|
||||
if notes[0].end-length>=F(1,2):
|
||||
pickup=tuple(dataclasses.replace(n,start=n.start+length,end=n.end+length) for n in source.pickup)
|
||||
changed=(*pickup,dataclasses.replace(notes[0],start=length),*notes[1:])
|
||||
if harmony.evaluate(changed,complete=True).valid:
|
||||
result.append((changed,(types.DraftEdit('pickup_entrance',F(),notes[0].end,(notes[0].event_id,),
|
||||
tuple(n.event_id for n in pickup)+(notes[0].event_id,),source.id+': bring source pickup inside the solo and shorten its first hold'),)))
|
||||
for i,(before,note) in enumerate(zip(notes,notes[1:]),1):
|
||||
if before.end == note.start and before.end-before.start >= 1 and note.end-note.start >= F(1,2):
|
||||
for displacement in (F(-1,2),F(1,2)):
|
||||
time = note.start+displacement
|
||||
if time-before.start < F(1,2) or note.end-time < F(1,2):
|
||||
continue
|
||||
changed=(*notes[:i-1],dataclasses.replace(before,end=time),dataclasses.replace(note,start=time),*notes[i+1:])
|
||||
if harmony.evaluate(changed,complete=True).valid:
|
||||
edit=types.DraftEdit('rhythmic_paraphrase',before.start,note.end,(before.event_id,note.event_id),(before.event_id,note.event_id),
|
||||
f'{source.id}: retain both pitches and their order; move shared boundary {displacement} beat')
|
||||
result.append((changed,(edit,)))
|
||||
# Structural color is chosen in relation to the original turn, never a new random contour.
|
||||
if 0 < i < len(notes)-1:
|
||||
after=notes[i+1]
|
||||
for p in available(harmony,note.start,note.end,members=note.end-note.start>=1):
|
||||
if p == note.pitch or abs(p.number()-note.pitch.number())>2:
|
||||
continue
|
||||
direction=lambda a,b:(b>a)-(b<a)
|
||||
if (direction(before.pitch.number(),p.number()),direction(p.number(),after.pitch.number())) != (direction(before.pitch.number(),note.pitch.number()),direction(note.pitch.number(),after.pitch.number())):
|
||||
continue
|
||||
changed=(*notes[:i],dataclasses.replace(note,pitch=p),*notes[i+1:])
|
||||
if harmony.evaluate(changed,complete=True).valid:
|
||||
edit=types.DraftEdit('melodic_paraphrase',note.start,note.end,(note.event_id,),(note.event_id,),f'{source.id}: {note.pitch.label()} to {p.label()}, retain rhythm and turn')
|
||||
result.append((changed,(edit,)))
|
||||
# Broaden or release an ending as an explicit whole-phrase alternative.
|
||||
if notes and notes[-1].end-notes[-1].start >= F(3,2):
|
||||
tail=dataclasses.replace(notes[-1],end=notes[-1].end-F(1,2))
|
||||
result.append(((*notes[:-1],tail),(types.DraftEdit('ending_release',tail.end,notes[-1].end,(tail.event_id,),(tail.event_id,),source.id+': half-beat breath after the arrival'),)))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def rhythm_catalog(library, meter):
|
||||
# Keep complete snippet objects. Slices below retain real rests and durations.
|
||||
return drafts.catalog(library,meter)
|
||||
|
||||
|
||||
def connection_patterns(start,end,entry,destination,rhythms,meter):
|
||||
length=end-start
|
||||
spans={( (F(),length), ):'held'}
|
||||
for identifier,pattern,total in rhythm_catalog(rhythms,meter):
|
||||
if total < length:
|
||||
continue
|
||||
# Retrieve every exact-duration window, including offbeat and cross-bar windows.
|
||||
for offset in sorted({F(),*(a for a,_ in pattern)}):
|
||||
if offset+length>total:
|
||||
continue
|
||||
if any(a<offset<b or a<offset+length<b for a,b in pattern):
|
||||
continue
|
||||
selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
|
||||
if 2<=len(selected)<=7 and selected[0][0]==0:
|
||||
spans.setdefault(selected,identifier)
|
||||
ranked=sequences.diverse(tuple(spans.items()),
|
||||
lambda item:(abs(len(item[0])-float(length)*1.5),item[1]),
|
||||
lambda item:(len(item[0]),any(a[1]!=b[0] for a,b in zip(item[0],item[0][1:]))),8)
|
||||
result=[]
|
||||
for pattern,identifier in ranked:
|
||||
events=tuple(dataclasses.replace(entry,start=start+a,end=start+b,role='connection',
|
||||
event_id=f'{entry.event_id}:route:{a}',position=None) for a,b in pattern)
|
||||
if len(events)>=2:
|
||||
result.append(((*events,destination),identifier))
|
||||
return result
|
||||
|
||||
|
||||
def elaborate(notes, features, harmony, library, rhythms, meter, source, *, sustain_entry=False):
|
||||
"""Edit an exposed held tail or a complete interior span, with both endpoints known."""
|
||||
fixed={e for f in features for e in f.events}
|
||||
windows=[]
|
||||
for i,(note,target) in enumerate(zip(notes,notes[1:])):
|
||||
if note.event_id in fixed or note.end != target.start:
|
||||
continue
|
||||
if note.end-note.start>=F(3,2):
|
||||
start=max(note.start+F(1,2),target.start-3)
|
||||
windows.append((i,start,target,False))
|
||||
# A source-held ending can arrive later via a related in-scale turn.
|
||||
if notes and notes[-1].event_id not in fixed and notes[-1].end-notes[-1].start>=F(5,2):
|
||||
tail=notes[-1]
|
||||
for duration in (F(1,2),F(1),F(3,2),F(2)):
|
||||
target=dataclasses.replace(tail,start=tail.end-duration,event_id=tail.event_id+':arrival')
|
||||
start=max(tail.start+F(1,2),target.start-3)
|
||||
if target.start-start>=1:
|
||||
windows.append((len(notes)-1,start,target,True))
|
||||
result=[]
|
||||
for i,start,target,terminal in windows:
|
||||
entry=notes[i];end=target.start
|
||||
prefix=dataclasses.replace(entry,end=start)
|
||||
seed=dataclasses.replace(entry,start=start,event_id=entry.event_id+':tail',role='connection')
|
||||
for pattern,rhythm_id in connection_patterns(start,end,seed,target,rhythms,meter):
|
||||
for route in routes.candidates(pattern,{target.event_id},harmony,library,(41,76),'develop '+source.id):
|
||||
if sustain_entry:
|
||||
# The route owns its complete sounding entry, including the old hold.
|
||||
joined=dataclasses.replace(route.notes[0],start=entry.start,event_id=entry.event_id)
|
||||
route=dataclasses.replace(route,notes=(joined,*route.notes[1:]))
|
||||
actual=(*notes[:i],*route.notes,*notes[i+1 if terminal else i+2:])
|
||||
else:
|
||||
actual=(*notes[:i],prefix,*route.notes,*notes[i+1 if terminal else i+2:])
|
||||
if not harmony.evaluate(actual,complete=True).valid:
|
||||
continue
|
||||
if any(abs(b.pitch.number()-a.pitch.number())>7 for a,b in zip(route.notes,route.notes[1:])):
|
||||
continue
|
||||
route=dataclasses.replace(route,sources=(*route.sources,'rhythm:'+rhythm_id))
|
||||
edit=types.DraftEdit('complete_route',start,target.start,(entry.event_id,),tuple(n.event_id for n in route.notes),
|
||||
source.id+'; '+rhythm_id+'; '+('later terminal arrival on ' if terminal else 'lead into ')+target.pitch.label())
|
||||
result.append((actual,edit,route))
|
||||
# Whole routes compete; bounded diversity retains different locations and rhythms.
|
||||
return sequences.diverse(result,lambda v:(abs(len(v[0])*4/float(source.end-source.start)-5),
|
||||
sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(v[2].notes,v[2].notes[1:])),v[2].id),
|
||||
lambda v:(v[1].start,v[2].destination.end-v[2].destination.start,len(v[2].notes)),8)
|
||||
|
||||
|
||||
def make_phrase(source, original, refined, notes, edits, route_list, octave, part, harmony, tempo, seed):
|
||||
from generator.solo import realization
|
||||
features=identifying(refined,source.id)
|
||||
# Only fully retained source turns become committed anchor events.
|
||||
anchors=[]
|
||||
for i,n in enumerate(notes):
|
||||
if n.pitch.number()%12 not in harmony.members(n.start,n.end):
|
||||
continue
|
||||
role='terminal' if i==len(notes)-1 else 'opening' if i==0 else 'intermediate'
|
||||
if role=='intermediate' and n.end-n.start<2:
|
||||
continue
|
||||
anchors.append(types.Anchor(n.event_id+':anchor',n.start,n.start,n.pitch,harmony.members(n.start,n.end),
|
||||
(41,76),n.end-n.start,role,harmony.at(n.start).chord,hold_until=n.end))
|
||||
if notes and (not anchors or anchors[-1].role!='terminal'):
|
||||
return None
|
||||
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,signature(notes))[:12]
|
||||
motif=types.Motif(source.id,tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start) for n in refined),
|
||||
source.end-source.start,tuple(s.chord for s in harmony.spans if s.start<source.end and s.end>source.start),'song melody '+source.family)
|
||||
home=realization.make_identity(identifier,source.id,refined,source.start,source.end,'source_variation',harmony)
|
||||
transform=types.Transformation(identifier,source.id,source.family,'source_variation',source.start,source.end,
|
||||
tuple(n.event_id for n in notes),tuple((n.start-source.start,n.end-source.start) for n in notes),preserves=(),source=source.id)
|
||||
draft=types.MelodicDraft(identifier,source.start,source.end,notes,(transform,),
|
||||
tuple(a.id.removesuffix(':anchor') for a in anchors),(),tuple(anchors),home,'source_variation',features=features,
|
||||
reference=source.id,reference_motif=motif,source_phrase=source,octave_transfer=octave,
|
||||
source_links=tuple((n.event_id,n.sources) for n in notes))
|
||||
spaces=[];cursor=source.start
|
||||
for n in notes:
|
||||
if n.start>cursor:spaces.append(types.ActivitySpan(cursor,n.start,'space',F(),F()))
|
||||
spaces.append(types.ActivitySpan(n.start,n.end,'active',F(5,4),F()))
|
||||
cursor=n.end
|
||||
if cursor<source.end:spaces.append(types.ActivitySpan(cursor,source.end,'space',F(),F()))
|
||||
if not notes:spaces=[types.ActivitySpan(source.start,source.end,'space',F(),F())]
|
||||
plan=types.PhrasePlan(identifier,source.start,source.end,motif,'source_variation','source_variation',None,(41,76),F(5,4),
|
||||
source.end-cursor,tuple(spaces),(), 'breath' if cursor<source.end else 'sustain','closed',seed,source.id,source.start,source.end, (tuple(anchors),))
|
||||
phrase=realization.checked_phrase(plan,draft,notes,(),types.ContinuationState(''),harmony,tempo,edits)
|
||||
if phrase is None:
|
||||
return None
|
||||
# Source correspondence is evaluated once. Do not score the imported tune's
|
||||
# characteristic leaps as generic contour defects or reward filling its rests.
|
||||
source_by_id={n.event_id:n for n in original}
|
||||
retained={n.event_id:n for n in notes if n.event_id in source_by_id}
|
||||
deviation=sum(min(1,abs(n.pitch.number()-retained[k].pitch.number())/5)+
|
||||
min(1,float(abs(n.start-retained[k].start))/2)+min(1,float(abs((n.end-n.start)-(retained[k].end-retained[k].start)))/2) for k,n in source_by_id.items() if k in retained)/max(1,len(source_by_id))
|
||||
exposure=harmony.evaluate(notes,complete=True).exposure
|
||||
density=len(notes)*4/max(.001,float(source.end-source.start))
|
||||
score=3*deviation+2*exposure+.35*max(0,4-density,density-6)
|
||||
source_draft=dataclasses.replace(draft,id=identifier+':source',notes=original,features=(),anchors=(),structural=())
|
||||
refined_draft=dataclasses.replace(draft,id=identifier+':variation',notes=refined,features=features,anchors=(),structural=())
|
||||
return dataclasses.replace(phrase,score=score,initial=source_draft,refined=refined_draft,routes=route_list)
|
||||
|
||||
|
||||
def candidates(source, harmony, sequences_library, rhythms, meter, part, tempo, seed, *, stage='elaborated'):
|
||||
result=[]
|
||||
for octave in range(-3,4):
|
||||
original=shift(source.notes,octave)
|
||||
if any(not generator.fretboard.single_positions_for_pitch(F(n.pitch.number())) for n in original):
|
||||
continue
|
||||
adapted,required=adapt(original,harmony)
|
||||
if original and not adapted and any(harmony.at(n.start) and harmony.at(n.start).scale for n in original):
|
||||
continue
|
||||
variants=list(variations(adapted,dataclasses.replace(source,pickup=shift(source.pickup,octave)),harmony))
|
||||
if required or not adapted:
|
||||
variants.append((adapted,()))
|
||||
# A whole preserved phrase is the baseline, not an accidental production fallback.
|
||||
if not variants:
|
||||
continue
|
||||
variants=sequences.diverse(variants,lambda v:(len(v[1]),policy.stable_rank(seed,signature(v[0]))),
|
||||
lambda v:(v[1][0].kind if v[1] else 'harmonic_adaptation',v[1][0].start if v[1] else source.start),8)
|
||||
for refined,changes in variants:
|
||||
options=[(refined,(),())]
|
||||
if stage=='elaborated':
|
||||
connections=elaborate(refined,identifying(refined,source.id),harmony,sequences_library,rhythms,meter,source)
|
||||
options.extend((notes,(edit,),(route,)) for notes,edit,route in connections)
|
||||
# A second, distinct source-held span may develop the same phrase.
|
||||
# Reuse complete routes; never elaborate an already elaborated event.
|
||||
for first_notes,first_edit,first_route in connections[:2]:
|
||||
fixed=(*identifying(refined,source.id),types.IdentifyingFeature('route',
|
||||
tuple(n.event_id for n in first_route.notes)+first_edit.removed,(),(),source.id))
|
||||
second=elaborate(first_notes,fixed,harmony,sequences_library,rhythms,meter,source)
|
||||
options.extend((actual,(first_edit,edit),(first_route,route)) for actual,edit,route in second[:2])
|
||||
for notes,added,route_list in options:
|
||||
phrase=make_phrase(source,original,refined,notes,(*required,*changes,*added),route_list,octave,part,harmony,tempo,seed)
|
||||
if phrase:
|
||||
result.append(phrase)
|
||||
# Preserve register AND edit-location diversity, rather than eight near-copies.
|
||||
unique={signature(p.draft.notes):p for p in result}
|
||||
return sequences.diverse(tuple(unique.values()),lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
|
||||
lambda p:p.draft.octave_transfer,policy.DEFAULT.source_variations)
|
||||
|
||||
|
||||
def clipped_sources(analysis,end):
|
||||
result=[]
|
||||
for source in analysis.source_phrases:
|
||||
if source.start>=end:break
|
||||
finish=min(source.end,end)
|
||||
notes=tuple(dataclasses.replace(n,start=max(F(),n.start),end=min(n.end,finish)) for n in source.notes if n.start<finish and n.end>0)
|
||||
result.append(dataclasses.replace(source,end=finish,notes=notes,rests=tuple((a,min(b,finish)) for a,b in source.rests if a<finish)))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def transition(first,second):
|
||||
if not first or not first.decisions or not second.decisions:return 0.
|
||||
a=first.state.last;b=second.decisions[0].notes[0]
|
||||
leap=max(0,abs(a.pitch.number()-b.pitch.number())-7)/12
|
||||
same_family=first.draft.source_phrase.family==second.draft.source_phrase.family
|
||||
repeated_end=not same_family and first.anchors and second.anchors and first.anchors[-1].pitch==second.anchors[-1].pitch and first.anchors[-1].duration==second.anchors[-1].duration
|
||||
return .2*leap+.15*bool(repeated_end)
|
||||
|
||||
|
||||
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
|
||||
from generator.solo import realization, themes
|
||||
if mode not in ('thematic','paraphrase'):raise ValueError('Unknown solo treatment: '+mode)
|
||||
context.resources[id(library)]=library
|
||||
context.resources[id(rhythms)]=rhythms
|
||||
harmony=harmonic.Harmony(choices)
|
||||
sources=themes.windows(analysis,measures,end,harmony) if mode=='thematic' else clipped_sources(analysis,end)
|
||||
seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE))
|
||||
states=[(0.,())]
|
||||
for source in sources:
|
||||
meter=measures[min(len(measures)-1,int(source.start//generator.model.fraction(measures[0]['duration'])))]['time_signature']
|
||||
following=[]
|
||||
for cost,history in states:
|
||||
incoming=history[-1].state if history else types.ContinuationState('')
|
||||
key=(mode,source,incoming if mode=='thematic' else None,policy.stable_rank("harmony",choices),part,analysis.tempo,seed,stage,id(library),id(rhythms),policy.DEFAULT)
|
||||
if key in context.results:
|
||||
pool=context.results[key];context.diagnostics['cache_hits']+=1
|
||||
else:
|
||||
if mode=='thematic' and any(c.scale and c.span.start<source.end and c.span.end>source.start for c in choices):
|
||||
pool=()
|
||||
for batch in range(2):
|
||||
pool=themes.phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,analysis.tempo,context,seed,stage=stage,batch=batch,final=source.end==end)
|
||||
if pool:break
|
||||
context.diagnostics['theme_replans']=context.diagnostics.get('theme_replans',0)+1
|
||||
elif mode=='thematic':
|
||||
pool=themes.silence(source,incoming,part,harmony,analysis.tempo,seed)
|
||||
else:
|
||||
pool=candidates(source,harmony,library,rhythms,meter,part,analysis.tempo,seed,stage=stage)
|
||||
context.results[key]=pool;context.diagnostics['generated_candidates']+=len(pool)
|
||||
context.diagnostics.setdefault('source_candidate_pools', {}).setdefault(source.id,[]).extend(
|
||||
{'id':p.draft.id,'score':p.score,'octave':p.draft.octave_transfer,'attacks':len(p.draft.notes),'edits':[e.kind for e in p.edits],'source_family':source.family,'theme':p.state.home.id if p.state.home else None} for p in pool)
|
||||
for candidate in pool:
|
||||
# The thematic constructor has already placed all decisions from the
|
||||
# actual incoming state; do not collapse its motif groups into one bar.
|
||||
if mode=='thematic':
|
||||
total=cost+candidate.score*float(source.end-source.start)/float(end)+transition(history[-1] if history else None,candidate)
|
||||
following.append((total,(*history,candidate)))
|
||||
continue
|
||||
last=incoming.last
|
||||
placements=placement.alternatives(tuple(n for d in candidate.decisions for n in d.notes),analysis.tempo,last,limit=2)
|
||||
for travel,notes in placements:
|
||||
decision=dataclasses.replace(candidate.decisions[0],notes=notes) if candidate.decisions else None
|
||||
state=dataclasses.replace(candidate.state,notes=notes,last=notes[-1] if notes else last,travel=travel)
|
||||
placed=dataclasses.replace(candidate,decisions=(decision,) if decision else (),state=state)
|
||||
total=cost+candidate.score*float(source.end-source.start)/float(end)+.05*travel/max(1,len(notes))/2000+transition(history[-1] if history else None,placed)
|
||||
following.append((total,(*history,placed)))
|
||||
if not following:
|
||||
raise realization.SearchExhausted(f'{part} {source.id}: {mode} feature, harmonic destination or continuation failed within the bounded search; not proof of incompatibility')
|
||||
states=sequences.diverse(following,lambda v:(v[0],policy.stable_rank(seed,tuple((p.draft.id,p.state.last.position if p.state.last else None) for p in v[1]))),
|
||||
lambda v:(v[1][-1].state.home.id if mode=='thematic' and v[1][-1].state.home else tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier)
|
||||
return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'thematic_recomposition' if mode=='thematic' else 'source_phrase_variations','policy':policy.describe()['fingerprint']},
|
||||
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in states)
|
||||
|
||||
|
||||
def similarity(first,second):
|
||||
"""Compare composition independently of octave and fingering; callbacks are explicit."""
|
||||
def events(solo):
|
||||
callbacks={e for p in solo.phrases if p.draft for f in p.draft.features if f.id.endswith(':callback') for e in f.events}
|
||||
return tuple(n for p in solo.phrases if p.plan.relationship!='callback' for d in p.decisions for n in d.notes if n.event_id not in callbacks)
|
||||
a,b=events(first),events(second)
|
||||
def tokens(notes):
|
||||
return {(n.start,n.end,n.pitch.number()%12) for n in notes}
|
||||
left,right=tokens(a),tokens(b)
|
||||
literal=len(left&right)/max(1,min(len(left),len(right)))
|
||||
def runs(notes):
|
||||
return {tuple((n.start-notes[i].start,n.end-n.start,n.pitch.number()-notes[i].pitch.number()) for n in notes[i:i+4]) for i in range(len(notes)-3)}
|
||||
left,right=runs(a),runs(b)
|
||||
interval=len(left&right)/max(1,min(len(left),len(right)))
|
||||
return .65*literal+.35*interval
|
||||
|
||||
+52
-14
@@ -19,25 +19,63 @@ def transition(previous, current, duration, tempo):
|
||||
# Movement is a preference, not an unsupported claim about a player's limits.
|
||||
return round(100 * distance / max(seconds, 0.05))
|
||||
|
||||
def hand_positions(position):
|
||||
return tuple(h for h in range(1, 10) if h <= position.fret <= h + 3)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def position_rank(position):
|
||||
from generator.solo.policy import stable_rank
|
||||
return stable_rank('guitar-position', position)
|
||||
|
||||
def stable_path(path):
|
||||
return tuple(position_rank(n.position) for n in path)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def place(notes: tuple[generator.solo.types.Note, ...], tempo: fractions.Fraction, previous: generator.solo.types.Note | None = None) -> tuple[int, tuple[generator.solo.types.Note, ...]] | None:
|
||||
states = [(0, ())]
|
||||
def alternatives(notes, tempo, previous=None, limit=2):
|
||||
"""Phrase paths with a four-fret hand region and distinct endpoint choices."""
|
||||
states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)]
|
||||
for note in notes:
|
||||
following = []
|
||||
following = {}
|
||||
for position in positions(note.pitch.number()):
|
||||
current = dataclasses.replace(note, position=position)
|
||||
candidates = []
|
||||
for cost, path in states:
|
||||
prior = path[-1] if path else previous
|
||||
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start):
|
||||
continue
|
||||
candidates.append((cost + (0 if note.continuation else travel(prior, current, tempo)), (*path, current)))
|
||||
if candidates:
|
||||
following.append(min(candidates, key=lambda item: (item[0], tuple(n.position for n in item[1]))))
|
||||
for hand in hand_positions(position):
|
||||
best = None
|
||||
for cost, path, old_hand in states:
|
||||
prior = path[-1] if path else previous
|
||||
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start):
|
||||
continue
|
||||
movement = 0
|
||||
if prior and not note.continuation:
|
||||
seconds = max(.05, float((note.start-prior.start)*60/tempo))
|
||||
distance = abs(hand-old_hand) if old_hand is not None else 0
|
||||
distance += .25*abs(position.fret-prior.position.fret) + .5*abs(position.string_number-prior.position.string_number)
|
||||
movement = round(100*distance/seconds)
|
||||
candidate = (cost+movement, (*path,current), hand)
|
||||
if best is None or candidate[0] < best[0] or candidate[0] == best[0] and stable_path(candidate[1]) < stable_path(best[1]):
|
||||
best = candidate
|
||||
if best is not None:
|
||||
following[position,hand] = best
|
||||
if not following:
|
||||
return None
|
||||
states = following
|
||||
return min(states, key=lambda item: (item[0], tuple(n.position for n in item[1])))
|
||||
return ()
|
||||
states = list(following.values())
|
||||
# Endpoints survive so the following phrase can choose a better transition.
|
||||
selected, seen = [], set()
|
||||
for cost,path,hand in sorted(states,key=lambda v:(v[0],stable_path(v[1]))):
|
||||
endpoint = path[-1].position if path else None
|
||||
if endpoint in seen:
|
||||
continue
|
||||
seen.add(endpoint); selected.append((cost,path))
|
||||
if len(selected) == limit:
|
||||
break
|
||||
return tuple(selected)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def place(notes, tempo, previous=None):
|
||||
result = alternatives(notes,tempo,previous,1)
|
||||
return result[0] if result else None
|
||||
|
||||
@functools.lru_cache(maxsize=128)
|
||||
def positions(number: int):
|
||||
|
||||
@@ -33,9 +33,14 @@ class Policy:
|
||||
occupancy_deadband: float = 0.1
|
||||
lead_activity_range: tuple[float, float] = (1.0, 1.5)
|
||||
lead_occupancy_range: tuple[float, float] = (0.85, 1.0)
|
||||
lead_identities: int = 12
|
||||
lead_realizations: int = 2
|
||||
chromatic_max_per_eight_bars: int = 4
|
||||
source_variations: int = 8
|
||||
chorus_frontier: int = 8
|
||||
theme_copy_threshold: float = 0.8
|
||||
theme_copy_beats: float = 8.0
|
||||
theme_copy_attacks: int = 4
|
||||
theme_seed_limit: int = 8
|
||||
theme_unit_alternatives: int = 8
|
||||
composition_version: str = 'thematic-recomposition-1'
|
||||
|
||||
# Preserve the former default root seeds while decoupling them from policy edits.
|
||||
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
|
||||
|
||||
@@ -524,7 +524,7 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
|
||||
return None
|
||||
features = evaluation.features(score_notes, previous=incoming.last if not shared else None, movement=travel + (incoming.travel if shared else 0))
|
||||
features.update(generator.solo.drafts.relationship_features(draft, notes))
|
||||
if draft.lead_identity:
|
||||
if draft.lead_identity or draft.source_phrase:
|
||||
supported = {e for feature in draft.features for e in feature.events}
|
||||
motions = [(a, b) for a, b in zip(notes, notes[1:]) if not (a.event_id in supported and b.event_id in supported)]
|
||||
features['contour'] = sum(min(1, max(0, abs(b.pitch.number() - a.pitch.number()) - 5) / 12) for a, b in motions) / max(1, len(motions))
|
||||
@@ -861,8 +861,7 @@ def revise_pair(first, second, incoming, choices, sequences, tempo, context):
|
||||
def alternatives(plan, measures, choices, sequences, rhythms, tempo, previous=None, context=None):
|
||||
context = context if context is not None else SearchContext()
|
||||
if plan.treatment == 'lead':
|
||||
from generator.solo import leads
|
||||
return leads.alternatives(plan, measures, choices, sequences, tempo, previous, context)
|
||||
raise ValueError('generic lead identities are retired; use composer.compose for source variations')
|
||||
# Keep resources alive while identity-keyed cache entries exist. A later
|
||||
# library cannot accidentally reuse the identity of a collected object.
|
||||
context.resources[id(sequences)] = sequences
|
||||
|
||||
@@ -93,14 +93,24 @@ def identifying_features(notes, source):
|
||||
def feature_error(feature, notes):
|
||||
by_id = {n.event_id: n for n in notes}
|
||||
actual = [by_id.get(identity) for identity in feature.events]
|
||||
if not all(actual):
|
||||
if not actual or not all(actual):
|
||||
return 1.0
|
||||
first = actual[0].start
|
||||
rhythm = tuple((n.start - first, n.end - n.start) for n in actual)
|
||||
movement = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:]))
|
||||
timing = sum(a != b for a, b in zip(rhythm, feature.rhythm)) / max(1, len(rhythm))
|
||||
contour = sum(((a > 0) - (a < 0)) != ((b > 0) - (b < 0)) or abs(a - b) > 2 for a, b in zip(movement, feature.intervals)) / max(1, len(movement))
|
||||
return (timing + contour) / 2
|
||||
errors = []
|
||||
if 'rhythm' in feature.preserves:
|
||||
errors.append(1.0 if len(rhythm) != len(feature.rhythm) else sum(a != b for a,b in zip(rhythm,feature.rhythm))/max(1,len(rhythm)))
|
||||
if 'intervals' in feature.preserves or 'contour' in feature.preserves:
|
||||
if len(movement) != len(feature.intervals) or not movement and feature.intervals:
|
||||
errors.append(1.0)
|
||||
else:
|
||||
errors.append(sum(((a>0)-(a<0)) != ((b>0)-(b<0)) or 'intervals' in feature.preserves and abs(a-b)>2 for a,b in zip(movement,feature.intervals))/max(1,len(movement)))
|
||||
if 'exact_intervals' in feature.preserves:
|
||||
errors.append(float(movement != feature.intervals))
|
||||
if 'pitches' in feature.preserves:
|
||||
errors.append(float(tuple(n.pitch.number() for n in actual) != feature.pitches))
|
||||
return max(errors,default=0.0)
|
||||
|
||||
def relationship(notes, reference):
|
||||
"""One joint relationship cost: timed movement, not absolute-pitch copying."""
|
||||
|
||||
@@ -12,6 +12,8 @@ import generator.scales.timeline
|
||||
import generator.solo.types
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.drafts
|
||||
import generator.solo.similarity
|
||||
import generator.solo.references
|
||||
|
||||
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str:
|
||||
region = generator.scales.timeline.Solo(time_offset, time_offset + solo.end, solo.part.capitalize() + ' Solo')
|
||||
@@ -27,12 +29,18 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
|
||||
plan = phrase.plan
|
||||
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
|
||||
lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}')
|
||||
if plan.treatment == 'lead':
|
||||
lines.append(' Lead range: 4–6 attacks per four beats across the passage; active occupancy 85–100%; local breaths and runs allowed.')
|
||||
if plan.treatment in ('source_variation', 'thematic_recomposition'):
|
||||
lines.append(' Activity preference: 4–6 attacks per four beats; source phrasing takes precedence. Occupancy is diagnostic, not a target.')
|
||||
if phrase.draft:
|
||||
draft = phrase.draft
|
||||
lines.append(f' Complete draft {draft.id}: {draft.relationship}; home {draft.home.id if draft.home else "none"}; {len(draft.notes)} attacks; rests {draft.rests()}')
|
||||
lines.append(f' Lead reference: {draft.reference}; selected features: ' + ', '.join(f.id for f in draft.features))
|
||||
if draft.source_phrase:
|
||||
lines.append(f' Source phrase {draft.source_phrase.id}; octave transfer {draft.octave_transfer:+}; source rests {draft.source_phrase.rests}; source-event links {draft.source_links}')
|
||||
if draft.source_reference:
|
||||
ref=draft.source_reference
|
||||
lines.append(f' Source influence {ref.id}: {ref.treatment}; source {ref.source_id}, beats {ref.start}-{ref.end}. Later developments refer to solo theme {phrase.state.home.id}.')
|
||||
lines.append(' Retained contracts: '+', '.join(f'{f.id}: {f.preserves}' for f in draft.features))
|
||||
for route in phrase.routes:
|
||||
lines.append(f' Complete route {route.id}: {route.notes[0].start}-{route.destination.start}; destination {route.destination.pitch.label()}; {route.relationship}; sources {route.sources}')
|
||||
for transform in draft.transformations:
|
||||
@@ -62,7 +70,7 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
|
||||
choice = next(c for c in solo.choices if c.span.start <= note.start < c.span.end)
|
||||
root = generator.scales.selection.root_number(choice.span.chord)
|
||||
degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12]
|
||||
function = 'chord degree ' + '/'.join(degrees) if degrees else 'chromatic approach' if note.role == 'chromatic_approach' else 'in-collection connection'
|
||||
function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection'
|
||||
lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}')
|
||||
lines.append('\nSounding harmonic spans (ties merged before classification):')
|
||||
for event in detail['harmonic_roles']:
|
||||
@@ -99,6 +107,30 @@ def structure(solo):
|
||||
entry.update({'draft': rational(dataclasses.asdict(phrase.draft)), 'draft_rests': rational(phrase.draft.rests()), 'edits': rational([dataclasses.asdict(e) for e in phrase.edits]), 'relationships': generator.solo.drafts.relationship_features(phrase.draft, tuple(n for d in phrase.decisions for n in d.notes)), 'home': phrase.state.home.id if phrase.state and phrase.state.home else None, 'current_development': phrase.state.development.id if phrase.state and phrase.state.development else None})
|
||||
entry['lead_stages'] = {name: {'id': draft.id, 'attacks': len(draft.notes), 'notes': rational([dataclasses.asdict(n) for n in draft.notes])} for name, draft in (('initial', phrase.initial), ('refined', phrase.refined)) if draft}
|
||||
entry['complete_routes'] = rational([dataclasses.asdict(r) for r in phrase.routes])
|
||||
if phrase.initial and phrase.refined:
|
||||
entry['head_comparison_plain']=generator.solo.similarity.compare(phrase.refined.notes,phrase.initial.notes)
|
||||
entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),phrase.initial.notes)
|
||||
if phrase.state and phrase.state.home:
|
||||
entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home)
|
||||
entry['score'] = phrase.score
|
||||
phrases.append(entry)
|
||||
return {'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'harmonic_roles': harmony.describe(solo.notes(), contracts), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
|
||||
return {'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'harmonic_roles': harmony.describe(solo.notes(), contracts), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
|
||||
|
||||
|
||||
def fretboard_report(solo):
|
||||
import collections
|
||||
import generator.fretboard
|
||||
notes=solo.notes()
|
||||
attacks=collections.Counter(); durations=collections.Counter(); forced=optional=0
|
||||
for note in notes:
|
||||
string=note.position.string_number
|
||||
attacks[string]+=1;durations[string]+=float(note.end-note.start)
|
||||
if string==1:
|
||||
if len(generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())))==1:forced+=1
|
||||
else:optional+=1
|
||||
total=sum(durations.values())
|
||||
return {'attacks_by_string':dict(sorted(attacks.items())),
|
||||
'duration_fraction_by_string':{s:d/max(.001,total) for s,d in sorted(durations.items())},
|
||||
'first_string_forced':forced,'first_string_optional':optional,
|
||||
'range_midi': [min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)] if notes else [],
|
||||
'cross_bar_sounding_events':sum(any(n.start < t < n.end for t in {c.span.start for c in solo.choices if c.span.beat == 1}) for n in notes)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import dataclasses
|
||||
import fractions
|
||||
|
||||
import generator.fretboard
|
||||
import generator.solo.placement
|
||||
import generator.solo.policy
|
||||
import generator.solo.sequences
|
||||
import generator.solo.types as types
|
||||
@@ -25,12 +26,24 @@ def advance(scale, previous, step):
|
||||
return tuple(outgoing[abs(step) - 1:abs(step)])
|
||||
|
||||
def candidates(notes, fixed, harmony, library, register, relationship='connect theme'):
|
||||
# Cache concrete routes only with their endpoints, timing, scale context and
|
||||
# vocabulary. The per-Harmony cache cannot leak across collection changes.
|
||||
if not hasattr(harmony,'route_cache'):
|
||||
harmony.route_cache={}
|
||||
harmony.route_libraries={}
|
||||
harmony.route_libraries[id(library)]=library
|
||||
key=(notes,frozenset(fixed),id(library),register)
|
||||
if key not in harmony.route_cache:
|
||||
harmony.route_cache[key]=_candidates(notes,fixed,harmony,library,register)
|
||||
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.route_cache[key])
|
||||
|
||||
def _candidates(notes, fixed, harmony, library, register):
|
||||
"""Every returned candidate includes its entry, complete rhythm and destination.
|
||||
|
||||
Callers validate the whole surrounding sounding context and guitar path.
|
||||
This function applies exact scale paths, fixed events and range constraints.
|
||||
"""
|
||||
if not 3 <= len(notes) <= 16:
|
||||
if not 2 <= len(notes) <= 16:
|
||||
return ()
|
||||
first, destination = notes[0], notes[-1]
|
||||
span = harmony.at(first.start)
|
||||
@@ -62,14 +75,14 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
|
||||
break
|
||||
if (template.event_id in fixed or template is destination) and pitch.number() != template.pitch.number():
|
||||
break
|
||||
if not generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch.number())):
|
||||
if not generator.solo.placement.positions(pitch.number()):
|
||||
break
|
||||
realized.append(dataclasses.replace(template, pitch=template.pitch if pitch.number() == template.pitch.number() else pitch, position=None))
|
||||
if len(realized) != len(notes):
|
||||
continue
|
||||
route_spans = tuple((max(first.start, c.span.start), min(destination.end, c.span.end), c.scale.label()) for c in harmony.choices if c.scale and c.span.start < destination.end and c.span.end > first.start)
|
||||
identity = generator.solo.policy.stable_rank('complete-route', (tuple(realized), path.sources))[:16]
|
||||
result.append(types.RouteCandidate(identity, tuple(realized), destination, route_spans, relationship, path.sources))
|
||||
result.append(types.RouteCandidate(identity, tuple(realized), destination, route_spans, 'connect theme', path.sources))
|
||||
return generator.solo.sequences.diverse(result,
|
||||
lambda r: (sum(abs(b.pitch.number() - a.pitch.number()) for a, b in zip(r.notes, r.notes[1:])), r.id),
|
||||
lambda r: generator.solo.sequences.family(tuple(b.pitch.number() - a.pitch.number() for a, b in zip(r.notes, r.notes[1:]))),
|
||||
|
||||
@@ -81,7 +81,7 @@ class Evaluation:
|
||||
deviation = max(0, abs(attacks - expected) - max(tolerance, generator.solo.policy.DEFAULT.activity_deadband * length / self.duration))
|
||||
activity += length * min(1, deviation / max(1, expected))
|
||||
occupancy += length * max(0, abs(occupied / length - occupied_goal) - generator.solo.policy.DEFAULT.occupancy_deadband)
|
||||
if self.plan.treatment == 'lead':
|
||||
if self.plan.treatment in ('lead', 'source_variation', 'thematic_recomposition'):
|
||||
# One broad range over the complete thought; quiet bars are not failed
|
||||
# local quotas. Missing harmony and explicit release are excluded here.
|
||||
active = tuple(window for window in self.windows if window[4] > 0)
|
||||
@@ -92,6 +92,8 @@ class Evaluation:
|
||||
activity = length * corridor(attacks / max(length, .001), low, high) / high
|
||||
low, high = generator.solo.policy.DEFAULT.lead_occupancy_range
|
||||
occupancy = length * corridor(occupied / max(length, .001), low, high)
|
||||
if self.plan.treatment in ('source_variation', 'thematic_recomposition'):
|
||||
occupancy = 0.0 # Space belongs to the source and its explicit edits.
|
||||
start, end = float(self.plan.start), float(self.until)
|
||||
durations = [max(0, min(b, end) - max(a, start)) for a, b, _ in sounded]
|
||||
register = sum(length * min(1, corridor(pitch, *self.plan.register) / 12) for (_, _, pitch), length in zip(sounded, durations)) / max(sum(durations), 0.001)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Duration-aware comparisons of realized music, independent of event labels."""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
|
||||
from generator.solo import types, policy
|
||||
|
||||
|
||||
def events(notes):
|
||||
return tuple((float(n.start),float(n.end),n.pitch.number()) for n in types.sounding_notes(notes))
|
||||
|
||||
|
||||
def occupancy(left, right, modulo=False):
|
||||
"""Intersection of pitch functions; repeated attacks cannot dilute a hold."""
|
||||
i=j=0; total=0.
|
||||
while i<len(left) and j<len(right):
|
||||
a,b=left[i],right[j]
|
||||
same = a[2]%12==b[2]%12 if modulo else a[2]==b[2]
|
||||
if same:
|
||||
total+=max(0.,min(a[1],b[1])-max(a[0],b[0]))
|
||||
if a[1]<=b[1]:i+=1
|
||||
else:j+=1
|
||||
return total
|
||||
|
||||
|
||||
def interval_match(left, right):
|
||||
"""Longest exact interval/rhythm fragment, allowing relocation/transposition."""
|
||||
def tokens(notes):
|
||||
return tuple((b[0]-a[0],a[1]-a[0],b[2]-a[2]) for a,b in zip(notes,notes[1:]))
|
||||
a,b=tokens(left),tokens(right); previous={}; best=(0.,0)
|
||||
for i,x in enumerate(a):
|
||||
current={}
|
||||
for j,y in enumerate(b):
|
||||
if x!=y:continue
|
||||
count=previous.get(j-1,0)+1
|
||||
current[j]=count
|
||||
# The last note's duration also belongs to the matching fragment.
|
||||
tail=min(left[i+1][1]-left[i+1][0],right[j+1][1]-right[j+1][0])
|
||||
duration=left[i+1][0]+tail-left[i+1-count][0]
|
||||
best=max(best,(duration,count+1))
|
||||
previous=current
|
||||
return best
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def compare_events(candidate, source):
|
||||
denominator=sum(b-a for a,b,_ in source)
|
||||
if not denominator:
|
||||
return dict(pitch_time=0.,octave_insensitive=0.,attack_agreement=0.,arrival_agreement=0.,
|
||||
interval_rhythm_coverage=0.,longest_fragment_beats=0.,longest_fragment_attacks=0)
|
||||
length,count=interval_match(candidate,source)
|
||||
attacks={(a,b,p%12) for a,b,p in candidate}
|
||||
arrivals=[n for n in source if n[1]-n[0]>=2]
|
||||
return dict(pitch_time=occupancy(candidate,source)/denominator,
|
||||
octave_insensitive=occupancy(candidate,source,True)/denominator,
|
||||
attack_agreement=sum((a,b,p%12) in attacks for a,b,p in source)/max(1,len(source)),
|
||||
arrival_agreement=sum((a,b,p%12) in attacks for a,b,p in arrivals)/max(1,len(arrivals)),
|
||||
interval_rhythm_coverage=min(1.,length/denominator),longest_fragment_beats=length,longest_fragment_attacks=count)
|
||||
|
||||
|
||||
def compare(candidate, source):
|
||||
return compare_events(events(candidate),events(source))
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def copy_evidence(candidate, source, start, end):
|
||||
"""Scan source-aligned windows, without an added-event denominator advantage."""
|
||||
minimum=policy.DEFAULT.theme_copy_beats
|
||||
threshold=policy.DEFAULT.theme_copy_threshold
|
||||
fragment,count=interval_match(candidate,source)
|
||||
copied=fragment>=minimum and count>=policy.DEFAULT.theme_copy_attacks
|
||||
best=0.; best_offset=0.
|
||||
if end-start>=minimum and candidate:
|
||||
windows=sorted({start,*(a for a,_,_ in candidate if start<=a<=end-minimum)})
|
||||
for left in windows:
|
||||
right=min(end,left+minimum)
|
||||
offsets={a[0]-b[0] for a in candidate if left<=a[0]<left+2 for b in source}
|
||||
for offset in sorted(offsets):
|
||||
window=tuple((max(left,a+offset),min(right,b+offset),p) for a,b,p in source if a+offset<right and b+offset>left)
|
||||
count_in=sum(left<=a+offset<right for a,_,_ in source)
|
||||
duration=sum(b-a for a,b,_ in window)
|
||||
if count_in<policy.DEFAULT.theme_copy_attacks or duration<minimum/2:
|
||||
continue
|
||||
score=occupancy(candidate,window,True)/duration
|
||||
if score>best:best,best_offset=score,offset
|
||||
if score>=threshold:copied=True
|
||||
return {'near_copy':copied,'aligned_occupancy':best,'alignment_offset':best_offset,
|
||||
'longest_fragment_beats':fragment,'longest_fragment_attacks':count}
|
||||
|
||||
|
||||
def near_copy(notes, source, start, end):
|
||||
return copy_evidence(events(notes),events(source),float(start),float(end))
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Thematic transformations used by the existing complete-draft composer.
|
||||
|
||||
Source features initialize a heard theme. Subsequent complete routes develop
|
||||
that realization, conditioned on the actual incoming musical state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
|
||||
import generator.model
|
||||
import generator.harmony
|
||||
from generator.solo import types, policy, sequences, routes, references, similarity
|
||||
|
||||
|
||||
def source_notes(analysis):
|
||||
return tuple(dataclasses.replace(n,start=max(F(),n.start-analysis.body_offset),end=n.end-analysis.body_offset,
|
||||
event_id=n.sources[0],position=None) for n in analysis.melody if n.end>analysis.body_offset)
|
||||
|
||||
|
||||
def windows(analysis, measures, end, harmony):
|
||||
"""Existing phrase representation; source extraction seams do not close it."""
|
||||
bounds=[F()]
|
||||
for measure in measures:bounds.append(bounds[-1]+generator.model.fraction(measure['duration']))
|
||||
melody=source_notes(analysis); result=[]; index=0
|
||||
while bounds[index]<end:
|
||||
stop=min(index+4,len(measures))
|
||||
# Permit an extension through a cadence rather than closing on its dominant.
|
||||
last=harmony.at(min(end,bounds[stop])-F(1,100))
|
||||
if last and last.chord!='N.C.' and generator.harmony.degrees(generator.harmony.parse_symbol(last.chord)).get(7)==-1 and generator.harmony.degrees(generator.harmony.parse_symbol(last.chord)).get(3)==0:
|
||||
for size in (6,8):
|
||||
candidate=min(index+size,len(measures)); span=harmony.at(min(end,bounds[candidate])-F(1,100))
|
||||
if span and span.chord!='N.C.' and generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)).get(3)==-1:
|
||||
stop=candidate;break
|
||||
start,finish=bounds[index],min(end,bounds[stop])
|
||||
notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.start<start)
|
||||
for n in melody if n.start<finish and n.end>start)
|
||||
result.append(types.SourcePhrase(f'theme_window_{start}_{finish}',start,finish,notes,(),''))
|
||||
index=stop
|
||||
# Explicit missing harmony interrupts sounding material, not thematic memory.
|
||||
divided=[]
|
||||
for window in result:
|
||||
cuts=sorted({window.start,window.end,*(max(window.start,s.start) for s in harmony.spans if not s.scale and s.start<window.end and s.end>window.start),*(min(window.end,s.end) for s in harmony.spans if not s.scale and s.start<window.end and s.end>window.start)})
|
||||
for start,finish in zip(cuts,cuts[1:]):
|
||||
notes=tuple(dataclasses.replace(n,start=max(start,n.start),end=min(finish,n.end),continuation=n.start<start) for n in window.notes if n.start<finish and n.end>start)
|
||||
divided.append(dataclasses.replace(window,id=f'theme_window_{start}_{finish}',start=start,end=finish,notes=notes))
|
||||
return tuple(divided)
|
||||
|
||||
|
||||
def silence(source,incoming,part,harmony,tempo,seed):
|
||||
from generator.solo import realization
|
||||
motif=types.Motif('intentional_space',(),source.end-source.start,(),'explicit missing harmony')
|
||||
plan=types.PhrasePlan(part+'_'+source.id,source.start,source.end,motif,'space','thematic_recomposition',None,(41,76),F(),F(),
|
||||
(types.ActivitySpan(source.start,source.end,'space',F(),F()),),passage_id=source.id,passage_start=source.start,passage_end=source.end)
|
||||
draft=types.MelodicDraft(plan.id,source.start,source.end,(),(),(),(),(),incoming.home,'space',source_phrase=source,source_reference=incoming.source_reference)
|
||||
phrase=realization.checked_phrase(plan,draft,(),incoming.treatments,incoming,harmony,tempo)
|
||||
if phrase is None:return ()
|
||||
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks)
|
||||
original=dataclasses.replace(draft,notes=source.notes)
|
||||
return (dataclasses.replace(phrase,state=state,initial=original,refined=draft,score=0.),)
|
||||
|
||||
|
||||
def short_fragment(source,incoming,part,harmony,tempo,seed,final):
|
||||
"""An explicitly reduced pickup around missing harmony; not a new theme."""
|
||||
from generator.solo import leads,realization
|
||||
reference=incoming.home.notes if incoming.home else source.notes
|
||||
if not reference:return ()
|
||||
template=dataclasses.replace(reference[0],start=source.start,end=source.end,position=None,continuation=False,event_id=source.id+':reduction')
|
||||
result=[]
|
||||
for pitch in pitches(harmony,template,incoming.last.pitch.number() if incoming.last else template.pitch.number(),members=True):
|
||||
note=dataclasses.replace(template,pitch=pitch)
|
||||
motif=types.Motif('reduction',reference,source.end-source.start,(),'explicit short fragment; no claim of full motif fidelity')
|
||||
anchor=types.Anchor(note.event_id+':arrival',note.start,note.start,pitch,harmony.members(note.start,note.end),(41,76),note.end-note.start,'terminal' if final else 'intermediate',harmony.at(note.start).chord,hold_until=note.end)
|
||||
transform=types.Transformation(note.event_id,reference[0].event_id,incoming.home.id if incoming.home else '', 'fragment',note.start,note.end,(note.event_id,),((F(),note.end-note.start),),preserves=())
|
||||
draft=types.MelodicDraft(part+'_'+note.event_id,source.start,source.end,(note,),(transform,),(note.event_id,),(),(anchor,),incoming.home,'fragment',source_phrase=source,source_reference=incoming.source_reference)
|
||||
plan=types.PhrasePlan(draft.id,source.start,source.end,motif,'fragment','thematic_recomposition',None,(41,76),F(5,4),F(),ending='sustain' if final else 'connected',closure='closed' if final else 'open',passage_id=source.id,passage_start=source.start,passage_end=source.end)
|
||||
phrase=realization.checked_phrase(plan,draft,(note,),incoming.treatments,incoming,harmony,tempo)
|
||||
if phrase:
|
||||
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks)
|
||||
result.append(dataclasses.replace(phrase,state=state,initial=draft,refined=draft))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def seeds(analysis, bar, seed):
|
||||
melody=tuple(dataclasses.replace(n,start=n.start-analysis.body_offset,end=n.end-analysis.body_offset,
|
||||
event_id=n.sources[0]) for n in analysis.melody)
|
||||
groups=[]
|
||||
for i in range(len(melody)):
|
||||
for count in ((len(melody),) if len(melody)<3 else (3,4,5)):
|
||||
group=melody[i:i+count]
|
||||
if len(group)!=count or group[-1].end-group[0].start>max(bar,F(3)) or group[0].start>=8*bar:
|
||||
continue
|
||||
local=tuple(dataclasses.replace(n,start=n.start-group[0].start,end=n.end-group[0].start) for n in group)
|
||||
parent=next((s.id for s in analysis.source_phrases if s.start<=max(0,group[0].start)<s.end),'pickup')
|
||||
for treatment in ('rhythmic_seed','contour_seed','opening_fragment','characteristic_interval'):
|
||||
identity=policy.stable_rank(seed,(group,treatment))[:12]
|
||||
groups.append(types.SourceReference(identity,parent,group[0].start,group[-1].end,local,treatment))
|
||||
def rank(ref):
|
||||
rhythm={(n.start,n.end-n.start) for n in ref.notes}
|
||||
intervals={b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])}
|
||||
return (-len(intervals),-len(rhythm),ref.start,ref.id)
|
||||
return sequences.diverse(groups,rank,lambda r:r.treatment,policy.DEFAULT.theme_seed_limit)
|
||||
|
||||
|
||||
def feature(reference, actual, *, treatment=None):
|
||||
treatment=treatment or reference.treatment
|
||||
required={'rhythmic_seed':('rhythm',),'contour_seed':('contour',),
|
||||
'opening_fragment':('rhythm','pitches'),'characteristic_interval':('exact_intervals',),
|
||||
'repeat_with_harmonic_adaptation':('rhythm','contour'),'answer':('contour',),
|
||||
'extend':('rhythm','contour'),'fragment':('rhythm',),'callback':('rhythm','pitches')}[treatment]
|
||||
original=reference.notes
|
||||
origin=original[0].start
|
||||
octave=12*round((actual[0].pitch.number()-original[0].pitch.number())/12)
|
||||
return types.IdentifyingFeature(reference.id+':'+treatment,tuple(n.event_id for n in actual),
|
||||
tuple((n.start-origin,n.end-n.start) for n in original),
|
||||
tuple(b.pitch.number()-a.pitch.number() for a,b in zip(original,original[1:])),reference.id,required,
|
||||
tuple(n.pitch.number()+octave for n in original))
|
||||
|
||||
|
||||
def rhythm_windows(library,meter,length,context):
|
||||
from generator.solo import drafts
|
||||
key=('theme-rhythm',id(library),meter,length)
|
||||
if key not in context.pools:
|
||||
patterns={}
|
||||
for identifier,pattern,total in drafts.catalog(library,meter):
|
||||
if total<length:continue
|
||||
for offset in sorted({F(),*(a for a,_ in pattern)}):
|
||||
if offset+length>total or any(a<offset<b or a<offset+length<b for a,b in pattern):continue
|
||||
selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
|
||||
if selected and len(selected)<=16:patterns.setdefault(selected,identifier)
|
||||
context.pools[key]=sequences.diverse(tuple(patterns.items()),
|
||||
lambda x:(not .7*float(length)<=len(x[0])<=1.8*float(length),policy.stable_rank('rhythm-window',x[0])),
|
||||
lambda x:(len(x[0]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8)
|
||||
return context.pools[key]
|
||||
|
||||
|
||||
def patterns(ref, start, finish, rhythms, meter, context, *, home=None, operation='introduce', terminal=False):
|
||||
length=finish-start; values={}
|
||||
if home and operation=='repeat_with_harmonic_adaptation':
|
||||
pattern=tuple((n.start,n.end) for n in home.notes if n.end<=length)
|
||||
if pattern:values[pattern]='heard-theme'
|
||||
prefix=tuple((n.start,n.end) for n in ref.notes)
|
||||
if ref.treatment in ('rhythmic_seed','opening_fragment','callback') or operation in ('extend','fragment','repeat_with_harmonic_adaptation'):
|
||||
duration=prefix[-1][1]
|
||||
if duration==length:values[prefix]=ref.id
|
||||
if duration<length:
|
||||
for tail,label in rhythm_windows(rhythms,meter,length-duration,context):
|
||||
values[(*prefix,*((a+duration,b+duration) for a,b in tail))]=ref.id+' + '+label
|
||||
else:
|
||||
values.update(rhythm_windows(rhythms,meter,length,context))
|
||||
if home and operation=='answer':
|
||||
for p,label in rhythm_windows(rhythms,meter,length,context):values.setdefault(p,label)
|
||||
if terminal:
|
||||
for original,label in tuple(values.items()):
|
||||
for hold in (F(1,2),F(1),F(3,2),F(2)):
|
||||
for release in (F(),F(1,2)):
|
||||
arrival=length-hold-release
|
||||
head=tuple((a,min(b,arrival)) for a,b in original if a<arrival)
|
||||
if len(head)>=len(ref.notes):values[(*head,(arrival,length-release))]=label+f'; arrival {hold}, release {release}'
|
||||
return sequences.diverse(tuple(values.items()),lambda x:(max(0,len(x[0])-1.5*float(length),float(length)-len(x[0])),policy.stable_rank(ref.id,x[0])),
|
||||
lambda x:(len(x[0]),x[0][-1][1]-x[0][-1][0],x[0][0][0]),4)
|
||||
|
||||
|
||||
def pitches(harmony,note,base,*,members=False,limit=6):
|
||||
from generator.solo.leads import available
|
||||
domain=available(harmony,note.start,note.end,members=members or note.end-note.start>=2)
|
||||
return sequences.diverse(domain,lambda p:(abs(p.number()-base),p.number()),
|
||||
lambda p:((p.number()-base>0)-(p.number()-base<0),p.number()//12),limit)
|
||||
|
||||
|
||||
def relative_steps(notes,harmony):
|
||||
result=[]
|
||||
for a,b in zip(notes,notes[1:]):
|
||||
scale=harmony.at(b.start).scale
|
||||
try:step=types.scale_index(scale,b.pitch.number())-types.scale_index(scale,a.pitch.number())
|
||||
except ValueError:
|
||||
lo,hi=sorted((a.pitch.number(),b.pitch.number()))
|
||||
intervening=range(lo+1,hi+1) if b.pitch.number()>a.pitch.number() else range(lo,hi)
|
||||
count=sum(p%12 in scale.pitches for p in intervening)
|
||||
step=count if b.pitch.number()>a.pitch.number() else -count
|
||||
result.append(step)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def instantiate(templates,first,steps,harmony):
|
||||
result=[dataclasses.replace(templates[0],pitch=first)]
|
||||
for note,step in zip(templates[1:],steps):
|
||||
span=harmony.at(note.start)
|
||||
if not span or not span.scale:return ()
|
||||
options=routes.advance(span.scale,result[-1].pitch.number(),step)
|
||||
if not options:return ()
|
||||
p=options[0]
|
||||
if not 41<=p.number()<=76 or p.number()%12 not in types.permitted(harmony.choices,note.start,note.end):return ()
|
||||
result.append(dataclasses.replace(note,pitch=p))
|
||||
return tuple(result) if len(result)==len(templates) else ()
|
||||
|
||||
|
||||
def prefix_paths(templates,ref,first,harmony,library):
|
||||
count=len(ref.notes); original=ref.notes
|
||||
if count==1:return ((dataclasses.replace(templates[0],pitch=first),),)
|
||||
if ref.treatment in ('opening_fragment','characteristic_interval','callback'):
|
||||
delta=first.number()-original[0].pitch.number()
|
||||
if ref.treatment in ('opening_fragment','callback') and delta%12:return ()
|
||||
result=[]
|
||||
from generator.solo.leads import available
|
||||
for template,n in zip(templates,original):
|
||||
target=n.pitch.number()+delta
|
||||
p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==target),None)
|
||||
if p is None:return ()
|
||||
result.append(dataclasses.replace(template,pitch=p))
|
||||
return (tuple(result),)
|
||||
paths=[]
|
||||
end_template=templates[count-1]
|
||||
source_motion=original[-1].pitch.number()-original[0].pitch.number()
|
||||
for target in pitches(harmony,end_template,first.number()+source_motion,limit=4):
|
||||
template=tuple(dataclasses.replace(n,pitch=first if i==0 else target) for i,n in enumerate(templates[:count]))
|
||||
paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,(41,76),'seed '+ref.id))
|
||||
valid=[p for p in paths if not references.feature_error(feature(ref,p),p)]
|
||||
return sequences.diverse(valid,lambda ns:(sum(abs(b.pitch.number()-a.pitch.number()) for a,b in zip(ns,ns[1:])),tuple(n.pitch for n in ns)),
|
||||
lambda ns:sequences.family(tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ns,ns[1:]))),4)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Unit:
|
||||
notes: tuple[types.Note,...]
|
||||
feature: types.IdentifyingFeature
|
||||
reference: types.SourceReference
|
||||
operation: str
|
||||
anchor: types.Anchor
|
||||
score: float
|
||||
source: str
|
||||
routes: tuple[types.RouteCandidate,...] = ()
|
||||
|
||||
|
||||
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0):
|
||||
# Position and the recent realization participate even when two paths share a last pitch.
|
||||
if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',harmony.choices)
|
||||
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,home,operation,terminal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
|
||||
if key in context.results:
|
||||
context.diagnostics['cache_hits']+=1;return context.results[key]
|
||||
result=[]; count=len(ref.notes)
|
||||
for pattern,label in patterns(ref,start,finish,rhythms,meter,context,home=home,operation=operation,terminal=terminal):
|
||||
if len(pattern)<count:continue
|
||||
templates=tuple(types.Note(start+a,start+b,ref.notes[0].pitch,tuple(s for n in ref.notes for s in n.sources),
|
||||
'theme',event_id=f'{ref.id}:{start}:{i}') for i,(a,b) in enumerate(pattern))
|
||||
base=incoming.last.pitch.number() if incoming.last else ref.notes[0].pitch.number()
|
||||
firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3)
|
||||
for first in firsts:
|
||||
proposed=[]
|
||||
if home and operation=='repeat_with_harmonic_adaptation' and len(templates)==len(home.notes):
|
||||
heard=instantiate(templates,first,home.degree_steps,harmony)
|
||||
if heard:proposed.append((heard,()))
|
||||
for prefix in prefix_paths(templates,ref,first,harmony,library):
|
||||
if len(prefix)==len(templates):
|
||||
proposed.append((prefix,()));continue
|
||||
base=prefix[-1].pitch.number()
|
||||
for target in pitches(harmony,templates[-1],base,members=True,limit=4 if batch else 2):
|
||||
tail=(prefix[-1],*templates[count:])
|
||||
tail=(*tail[:-1],dataclasses.replace(tail[-1],pitch=target))
|
||||
for route in routes.candidates(tail,{tail[-1].event_id},harmony,library,(41,76),operation+' '+ref.id):
|
||||
proposed.append(((*prefix[:-1],*route.notes),(route,)))
|
||||
for actual,connections in proposed:
|
||||
core=actual[:count]; contract=feature(ref,core,treatment=operation if operation!='introduce' else None)
|
||||
if references.feature_error(contract,actual):continue
|
||||
last=actual[-1]
|
||||
if last.pitch.number()%12 not in harmony.members(last.start,last.end):continue
|
||||
checked=harmony.evaluate(actual,until=finish,complete=True)
|
||||
if not checked.valid:continue
|
||||
density=len(actual)*4/float(finish-start)
|
||||
entry=max(0,abs(first.number()-incoming.last.pitch.number())-5)/12 if incoming.last else 0.
|
||||
leaps=sum(max(0,abs(b.pitch.number()-a.pitch.number())-5)/12 for a,b in zip(actual,actual[1:]))/max(1,len(actual)-1)
|
||||
# A repeat preserves the full heard idea. Answers and extensions
|
||||
# preserve their declared fragment, not every event of the old ending.
|
||||
development=references.relationship(actual,home) if home and operation=='repeat_with_harmonic_adaptation' else 0.
|
||||
score=2*checked.exposure+entry+leaps+development+.35*max(0,4-density,density-6)
|
||||
anchor=types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76),
|
||||
last.end-last.start,'terminal' if terminal else 'intermediate',harmony.at(last.start).chord,hold_until=last.end)
|
||||
result.append(Unit(actual,contract,ref,operation,anchor,score,label,connections))
|
||||
unique={tuple((n.start,n.end,n.pitch) for n in u.notes):u for u in result}
|
||||
selected=sequences.diverse(tuple(unique.values()),lambda u:(u.score,policy.stable_rank(ref.id,u.notes)),
|
||||
lambda u:(u.notes[0].pitch.number()//12,u.notes[-1].pitch.number()%12,tuple(n.end-n.start for n in u.notes[:count])),8)
|
||||
context.diagnostics['generated_candidates']+=len(selected)
|
||||
context.results[key]=selected
|
||||
return selected
|
||||
|
||||
|
||||
def home_reference(home,operation):
|
||||
notes=home.notes[:min(4,len(home.notes))]
|
||||
# A short characteristic beginning leaves room for a newly composed answer.
|
||||
while len(notes)>3 and notes[-1].end>home.duration/2:notes=notes[:-1]
|
||||
return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,
|
||||
'rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','extend','fragment') else 'contour_seed')
|
||||
|
||||
|
||||
def make_home(unit,harmony,start=None,end=None):
|
||||
from generator.solo.realization import make_identity
|
||||
start=unit.notes[0].start if start is None else start
|
||||
identity=policy.stable_rank('solo-theme',(unit.reference.id,unit.notes))[:16]
|
||||
home=make_identity('solo_theme_'+identity,unit.reference.id,unit.notes,start,unit.notes[-1].end if end is None else end,'introduce',harmony)
|
||||
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony))
|
||||
|
||||
|
||||
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False):
|
||||
from generator.solo import leads,realization
|
||||
if source.end-source.start<2:
|
||||
return short_fragment(source,incoming,part,harmony,tempo,seed,final)
|
||||
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
|
||||
unit_length=2*bar
|
||||
head=source_notes(analysis)
|
||||
seed_key=('source-seeds',analysis,bar,seed)
|
||||
if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed)
|
||||
roots=context.pools[seed_key]
|
||||
states=[(0.,(),incoming.home,incoming)]
|
||||
cursor=source.start
|
||||
while cursor<source.end:
|
||||
finish=min(cursor+unit_length,source.end)
|
||||
next_states=[]
|
||||
for cost,units,home,state in states:
|
||||
introducing=home is None
|
||||
if introducing:
|
||||
requests=[(r,'introduce') for r in roots]
|
||||
else:
|
||||
operations=('repeat_with_harmonic_adaptation',) if cursor==source.start and source.start<=4*bar else ('repeat_with_harmonic_adaptation','answer','extend')
|
||||
requests=[(home_reference(home,op),op) for op in operations]
|
||||
used=incoming.callbacks+sum(u.operation=='callback' for u in units)
|
||||
if cursor>=8*bar and cursor==source.start and used<2:
|
||||
requests.extend((dataclasses.replace(r,treatment='callback'),'callback') for r in roots[:2] if r.end-r.start<=bar)
|
||||
for ref,operation in requests:
|
||||
if ref.notes[-1].end>finish-cursor:
|
||||
reduced=tuple(n for n in ref.notes if n.end<=finish-cursor)
|
||||
if len(reduced)<2:continue
|
||||
ref=dataclasses.replace(ref,notes=reduced,treatment='contour_seed')
|
||||
operation='answer'
|
||||
options=unit_candidates(ref,cursor,finish,state,harmony,library,rhythms,meter,context,home=home,operation=operation,
|
||||
terminal=final and finish==source.end,batch=batch)
|
||||
for unit in options:
|
||||
if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']:
|
||||
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
|
||||
actual_home=home or make_home(unit,harmony,cursor,finish)
|
||||
following=dataclasses.replace(state,home=actual_home,last=unit.notes[-1],notes=unit.notes,source_reference=state.source_reference or ref)
|
||||
next_states.append((cost+unit.score,(*units,unit),actual_home,following))
|
||||
states=sequences.diverse(next_states,lambda v:(v[0],policy.stable_rank(seed,tuple(u.notes for u in v[1]))),
|
||||
lambda v:(v[2].id,v[1][-1].notes[-1].pitch.number()),2 if incoming.home and finish<source.end else 8)
|
||||
if not states:return ()
|
||||
cursor=finish
|
||||
result=[]
|
||||
for cost,units,home,state in states:
|
||||
plain=tuple(n for u in units for n in u.notes)
|
||||
if similarity.near_copy(plain,head,source.start,source.end)['near_copy']:
|
||||
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
|
||||
features=tuple(u.feature for u in units)
|
||||
anchors=tuple(u.anchor for u in units)
|
||||
options=[(plain,(),())]
|
||||
if stage=='elaborated':
|
||||
protected=(*features,types.IdentifyingFeature('destinations',tuple(n.event_id for n in plain if any(n.event_id+':arrival'==a.id for a in anchors)),(),(),'',()))
|
||||
for actual,edit,route in leads.elaborate(plain,protected,harmony,library,rhythms,meter,source,sustain_entry=True):
|
||||
options.append((actual,(edit,),(route,)))
|
||||
for notes,edits,selected_routes in options:
|
||||
identifier=part+'_'+source.id+'_'+policy.stable_rank(seed,leads.signature(notes))[:12]
|
||||
transforms=tuple(types.Transformation(identifier+f':{i}',u.reference.id,home.id,u.operation,u.notes[0].start,u.notes[-1].end,
|
||||
tuple(n.event_id for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),tuple((n.start-u.notes[0].start,n.end-u.notes[0].start) for n in notes if u.notes[0].start<=n.start<u.notes[-1].end),preserves=(),source=u.reference.id+'; rhythm '+u.source) for i,u in enumerate(units))
|
||||
source_ref=state.source_reference
|
||||
draft=types.MelodicDraft(identifier,source.start,source.end,notes,transforms,
|
||||
tuple(a.id.removesuffix(':arrival') for a in anchors),(),anchors,home,'introduce' if not incoming.home else 'develop',
|
||||
features=features,reference=source_ref.id,reference_motif=home,source_phrase=source,source_reference=source_ref,
|
||||
source_links=tuple((n.event_id,n.sources) for n in notes))
|
||||
# One shared musical context crosses computational/source boundaries.
|
||||
activity=[];cursor=source.start
|
||||
for n in notes:
|
||||
if n.start>cursor:activity.append(types.ActivitySpan(cursor,n.start,'space',F(),F()))
|
||||
activity.append(types.ActivitySpan(n.start,n.end,'active',F(5,4),F()))
|
||||
cursor=n.end
|
||||
if cursor<source.end:activity.append(types.ActivitySpan(cursor,source.end,'space',F(),F()))
|
||||
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
|
||||
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,(41,76),F(5,4),
|
||||
source.end-notes[-1].end,tuple(activity),(), 'sustain' if final else 'connected','closed' if final else 'open',seed,
|
||||
source.id,source.start,source.end,(anchors,))
|
||||
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
|
||||
if phrase is None:continue
|
||||
density=len(notes)*4/float(source.end-source.start)
|
||||
score=cost/len(units)+.35*(max(0,4-density,density-6)-max(0,4-len(plain)*4/float(source.end-source.start),len(plain)*4/float(source.end-source.start)-6))
|
||||
score+=.05*phrase.state.travel/max(1,len(notes))/2000
|
||||
current=dataclasses.replace(phrase.state,home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units))
|
||||
# Store the actual introduced sound, including any legal elaboration.
|
||||
if not incoming.home:
|
||||
introduced=tuple(n for n in notes if n.start<units[0].notes[-1].end)
|
||||
heard=dataclasses.replace(home,notes=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced),
|
||||
degree_steps=relative_steps(introduced,harmony))
|
||||
current=dataclasses.replace(current,home=heard,themes=(heard,))
|
||||
source_control=leads.shift(source.notes, next((o for o in (0,1,-1,2,-2) if all(41<=n.pitch.number()+12*o<=76 for n in source.notes)),0))
|
||||
original=dataclasses.replace(draft,id=identifier+':source',notes=source_control,features=(),anchors=(),structural=())
|
||||
refined=dataclasses.replace(draft,id=identifier+':plain',notes=plain)
|
||||
result.append(dataclasses.replace(phrase,score=score,state=current,initial=original,refined=refined,routes=(*tuple(r for u in units for r in u.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in notes) for n in r.notes)),*selected_routes)))
|
||||
return sequences.diverse(result,lambda p:(p.score,policy.stable_rank(seed,p.draft.id)),
|
||||
lambda p:(p.state.home.id,p.state.last.pitch.number(),sum(e.kind=='complete_route' for e in p.edits)),8)
|
||||
@@ -57,6 +57,16 @@ class Motif:
|
||||
def contour(self) -> tuple[int, ...]:
|
||||
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SourcePhrase:
|
||||
id: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
notes: tuple[Note, ...]
|
||||
rests: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
|
||||
family: str
|
||||
pickup: tuple[Note, ...] = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class TargetEvent:
|
||||
earliest: fractions.Fraction
|
||||
@@ -125,6 +135,7 @@ class RealizedMotif:
|
||||
treatment: str
|
||||
chord_degrees: tuple[tuple[int, ...], ...] = ()
|
||||
source_start: fractions.Fraction = fractions.Fraction()
|
||||
degree_steps: tuple[int, ...] = ()
|
||||
def contour(self):
|
||||
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
|
||||
|
||||
@@ -146,6 +157,8 @@ class ContinuationState:
|
||||
selected_anchors: tuple[Anchor, ...] = ()
|
||||
ending: str = ''
|
||||
lead_identity: LeadIdentity | None = None
|
||||
source_reference: SourceReference | None = None
|
||||
callbacks: int = 0
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Transformation:
|
||||
@@ -177,6 +190,17 @@ class IdentifyingFeature:
|
||||
rhythm: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
|
||||
intervals: tuple[int, ...]
|
||||
source: str
|
||||
preserves: tuple[str, ...] = ('rhythm', 'intervals')
|
||||
pitches: tuple[int, ...] = ()
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SourceReference:
|
||||
id: str
|
||||
source_id: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
notes: tuple[Note, ...]
|
||||
treatment: str
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RouteCandidate:
|
||||
@@ -205,6 +229,10 @@ class MelodicDraft:
|
||||
reference_motif: Motif | RealizedMotif | None = None
|
||||
lead_identity: LeadIdentity | None = None
|
||||
correspondence: tuple[tuple[str, str], ...] = ()
|
||||
source_phrase: SourcePhrase | None = None
|
||||
octave_transfer: int = 0
|
||||
source_links: tuple[tuple[str, tuple[str, ...]], ...] = ()
|
||||
source_reference: SourceReference | None = None
|
||||
def rests(self):
|
||||
cursor, result = self.start, []
|
||||
for note in self.notes:
|
||||
|
||||
Reference in New Issue
Block a user