This commit is contained in:
khodowany
2026-10-03 12:44:53 -05:00
parent 59afd78205
commit 04c6ea2e00
25 changed files with 673 additions and 185 deletions
+172
View File
@@ -0,0 +1,172 @@
"""One learned accompaniment per instrument, reused at authored form positions."""
from __future__ import annotations
import copy
from fractions import Fraction as F
import generator.accompaniment
import generator.acoustic
import generator.acoustic_voicing
import generator.electric
import generator.guitar
import generator.fretboard
import generator.model
import generator.parts
import generator.score
from generator.arrange import form
import generator.arrange.pickup
def harmonic_key(measure, previous):
def key(chord):
return repr({k: chord.get(k) for k in ('root', 'kind', 'degrees', 'bass', 'inversion')}) if chord else ''
active = generator.accompaniment.active_chord(measure, F(), previous)
return (key(active), tuple((generator.model.fraction(h['onset']), key(h)) for h in measure['harmonies']
if generator.model.fraction(h['onset']) > 0), measure['duration'], measure['time_signature'])
def voice(source, document, measures, part, initial=None, source_offset=0):
staff = document['staves'][0]
result = generator.score.make_arranged_document(source, staff, staff_id=staff['id'],
staff_name=staff['name'], role='guitar', arrangement_part_type=part, measures=measures)
if part == 'acoustic':
generator.acoustic.prepare(result)
generator.acoustic_voicing.place(result)
generator.acoustic.combine(result)
else:
generator.guitar.place_guitar(result)
# Melody grips remain optional candidates from the same source position.
# Only genuine chord tones are copied: exposed melody tensions must not
# become a repeated accompaniment tension behind every solo.
sections = form.full_arrangement_sections(generator.score.source_staff(source)['measures'], part)
first = sum(s.measure_count for s in sections if s.section_index < 2)
head = document['staves'][0]['measures'][first + source_offset:first + source_offset + len(measures)]
previous = generator.accompaniment.carried_chords(measures, initial)
links = []
for i, measure in enumerate(measures):
if i >= len(head): break
for note in generator.score.measure_notes(measure):
if not note['pitches']: continue
chord = generator.accompaniment.active_chord(measure, generator.model.fraction(note['onset']), previous[i])
requirements = generator.fretboard.chord_requirements(chord)
allowed = set(requirements.allowed)
matches = [n for n in generator.score.measure_notes(head[i]) if len(n['pitches']) >= 3
and {int(generator.model.pitch_number(p)) % 12 for p in n['pitches']} <= allowed
and requirements.covers({int(generator.model.pitch_number(p)) % 12 for p in n['pitches']})]
links.append(generator.electric.VoicingLink(note, tuple(matches)))
generator.electric.reuse_voicings(links)
generator.model.validate_document(result)
generator.fretboard.validate_document(result)
return result['staves'][0]['measures']
def install(source, document, plans):
"""Freeze complete canonical grips; copies are never placed a second time."""
from generator.arrange.generate import section_source
part = document['staves'][0]['arrangement_part_type']
if part == 'vocal': return
authored = generator.score.source_staff(source)['measures']
sections = form.full_arrangement_sections(authored, part)
if 1 not in plans or not any(s.section_index == 1 for s in sections):
return [] # A standalone section already is its own canonical part.
body = generator.score.body_start_measure_index(authored)
body_measures = copy.deepcopy(authored[body:])
for m in body_measures:
for h in m['harmonies']: h.pop('chord_optional', None)
previous = generator.accompaniment.carried_chords(authored)
plan = plans[1]
def build(measures, prior, offset=0):
carried = generator.accompaniment.carried_chords(measures, prior)
result = []
for i, m in enumerate(measures):
j = offset + i
rhythm = (plan.acoustic_bass[j], plan.acoustic_upper[j]) if part == 'acoustic' else plan.electric[j]
result.append(generator.parts.build_measure(m, f'canonical_{part}_{j}', 'accompaniment', part, carried[i], rhythm=rhythm))
return result
canonical = voice(source, document, build(body_measures, previous[body]), part, previous[body])
canonical_previous = generator.accompaniment.carried_chords(body_measures, previous[body])
keys = [harmonic_key(m, p) for m, p in zip(body_measures, canonical_previous)]
cache, exceptions = {}, []
destination = 0
for si, section in enumerate(sections):
if section.role != 'accompaniment':
destination += section.measure_count
continue
effective = section_source(authored, section, sections[si + 1] if si + 1 < len(sections) else None)
carried = generator.accompaniment.carried_chords(effective)
for j in range(section.measure_count):
source_index = section.source_measure_index + j
index = source_index - body
old = document['staves'][0]['measures'][destination + j]
key = harmonic_key(effective[source_index], carried[source_index])
if key == keys[index]:
template = canonical[index]
else:
# Changed pickups/ending harmony reuse the rhythm and only revoice the
# affected measure. These are chart differences, never solo responses.
identity = (index, repr(key))
if identity not in cache:
cache[identity] = voice(source, document,
build([effective[source_index]], carried[source_index], index), part, carried[source_index], index)[0]
template = cache[identity]
exceptions.append({'measure': old['number'], 'source_measure': authored[source_index]['number'], 'reason': 'changed harmony or arrangement boundary'})
replacement = copy.deepcopy(old)
replacement['voices'] = copy.deepcopy(template['voices'])
replacement['id'] = 'canonical_' + old['id']
generator.score.reidentify([replacement], [])
replacement['id'] = old['id']
following = sections[si + 1] if si + 1 < len(sections) else None
if j == section.measure_count-1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
sounding = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(authored[0]) if n['pitches']]
if sounding:
cutoff = generator.arrange.pickup.offset(old, authored[0]) + min(sounding)
prefix = [n for n in generator.score.measure_notes(replacement) if generator.model.fraction(n['onset']) < cutoff]
for n in prefix: generator.arrange.pickup.truncate(n, cutoff)
# Preserve original event IDs: incoming melody ties and tuplets refer
# to these exact pickup events, not to a freshly voiced copy.
tail = copy.deepcopy([n for n in generator.score.measure_notes(old) if generator.model.fraction(n['onset']) >= cutoff])
identifier = old['voices'][0]['id']
for n in prefix: n['voice'] = identifier
replacement['voices'] = [{'id': identifier, 'notes': prefix + tail}]
exceptions.append({'measure': old['number'], 'source_measure': authored[source_index]['number'], 'reason': 'protected melody pickup'})
document['staves'][0]['measures'][destination + j] = replacement
destination += section.measure_count
# Build-only diagnostics live outside the published musical schema.
return exceptions
def audit(source, documents):
"""Compare music, not generated IDs, at every reused form position."""
authored = generator.score.source_staff(source)['measures']
result = {}
for part, document in documents.items():
if part == 'vocal': continue
sections = form.full_arrangement_sections(authored, part)
measures = document['staves'][0]['measures']
carried = generator.accompaniment.carried_chords(measures)
seen, exceptions, offset, reused = {}, [], 0, 0
for si, section in enumerate(sections):
following = sections[si + 1] if si + 1 < len(sections) else None
for j in range(section.measure_count):
if section.role != 'accompaniment': continue
m = measures[offset+j]
if j == section.measure_count-1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
exceptions.append({'measure': m['number'], 'reason': 'protected melody pickup'})
continue
key = (section.source_measure_index+j, repr(harmonic_key(m, carried[offset+j])))
signature = tuple((generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
tuple((generator.model.pitch_number(p),p['tablature']['string'],p['tablature']['fret']) for p in n['pitches']))
for n in generator.score.measure_notes(m))
if key in seen:
if signature != seen[key]:
raise ValueError(f'{part}, arrangement measure {m["number"]}: canonical accompaniment changed')
reused += 1
else:
if any(k[0] == key[0] for k in seen):
exceptions.append({'measure': m['number'], 'reason': 'changed effective harmony or duration'})
seen[key] = signature
offset += section.measure_count
result[part] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions}
return result
+1 -1
View File
@@ -28,7 +28,7 @@ def section_role(part_type: str, section_index: int) -> typing.Literal["accompan
return "slash"
if section_index == 2:
return "melody"
if section_index in {1, 5, 6}:
if section_index in {1, 3, 5, 6}:
return "accompaniment"
return "rest"
return "vocal" if section_index in {1, 5, 6} else "rest"
+11 -8
View File
@@ -5,6 +5,7 @@ import fractions
import pathlib
import generator.accompaniment
import generator.arrange.accompaniment
import generator.acoustic
import generator.acoustic_voicing
import generator.arrange.form
@@ -45,15 +46,15 @@ def section_source(source: list[dict], section, next_section=None) -> list[dict]
def plan_arrangement(document: dict, rhythms: generator.rhythm.Library) -> dict[int, generator.accompaniment.Plan]:
source = generator.score.source_staff(document)['measures']
body = generator.score.body_start_measure_index(source)
previous = generator.accompaniment.carried_chords(source)
canonical = generator.accompaniment.plan_section(source[body:], rhythms, initial=previous[body], electric=True)
result = {}
sections = generator.arrange.form.full_arrangement_sections(source, 'acoustic')
for index, section in enumerate(sections):
if section.role == 'slash':
continue
measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
previous = generator.accompaniment.carried_chords(measures)
start = section.source_measure_index
result[section.section_index] = generator.accompaniment.plan_section(measures[start:start + section.measure_count], rhythms, initial=previous[start], electric=section.section_index in (1, 5, 6))
for section in generator.arrange.form.full_arrangement_sections(source, 'acoustic'):
start = section.source_measure_index - body
end = start + section.measure_count
result[section.section_index] = generator.accompaniment.Plan(canonical.acoustic_bass[start:end],
canonical.acoustic_upper[start:end], canonical.electric[start:end])
return result
def _arrange_document(document: dict, part_type: str, plans: dict[int, generator.accompaniment.Plan], *, scales: generator.scales.library.Library | None = None, reports: list[str] | None = None, source_name: str = '') -> dict:
@@ -129,6 +130,8 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
# the complete performance, including its lead-in and repeated choruses.
for number, measure in enumerate(output, 1):
measure['number'] = str(number)
if part_type != 'vocal':
generator.arrange.accompaniment.install(document, result, plans)
generator.fretboard.validate_document(result)
if scales and solos:
report = generator.scales.mapping.annotate(result, solos, scales, source_name or document['metadata']['title'])
+16 -61
View File
@@ -4,14 +4,11 @@ import copy
import fractions
import generator.accompaniment
import generator.acoustic
import generator.acoustic_voicing
import generator.arrange.accompaniment
import generator.arrange.form
import generator.electric
import generator.fretboard
import generator.guitar
import generator.model
import generator.parts
import generator.score
import generator.solo.analysis
import generator.solo.composer
@@ -41,57 +38,11 @@ def annotate(document, solo, offset):
spans.append({'id': f'{solo.part}_scale_{len(spans) + 1}', 'start': generator.model.rational(offset + choice.span.start), 'end': generator.model.rational(offset + choice.span.end), 'label': label, 'matched': True})
document['staves'][0]['scale_spans'] = spans
def reuse_head_grips(document, references, choices):
previous = generator.accompaniment.carried_chords(references)
target_previous = generator.accompaniment.carried_chords(document['staves'][0]['measures'])
links, absolute = [], fractions.Fraction()
for index, measure in enumerate(document['staves'][0]['measures']):
for note in generator.score.measure_notes(measure):
onset = generator.model.fraction(note['onset'])
chord = generator.accompaniment.active_chord(measure, onset, target_previous[index])
if not note['pitches'] or chord is None:
continue
allowed = generator.solo.types.permitted(choices, absolute + onset, absolute + onset + generator.model.fraction(note['duration']))
matches = []
for candidate in generator.score.measure_notes(references[index]):
if len(candidate['pitches']) < 3:
continue
time = generator.model.fraction(candidate['onset'])
head_chord = generator.accompaniment.active_chord(references[index], time, previous[index])
if generator.accompaniment.same_chord(chord, head_chord) and {int(generator.model.pitch_number(p)) % 12 for p in candidate['pitches']} <= allowed:
matches.append((abs(time - onset), candidate['id'], candidate))
links.append(generator.electric.VoicingLink(note, tuple(item[2] for item in sorted(matches))))
absolute += generator.model.fraction(measure['duration'])
generator.electric.reuse_voicings(links)
def backing(baseline, foreground, part, rhythms, references, choices, initial=None):
plan = generator.accompaniment.plan_section(foreground, rhythms, initial=initial, electric=part == 'electric', acoustic_companions=False)
previous = generator.accompaniment.carried_chords(foreground, initial)
measures = []
for index, measure in enumerate(foreground):
rhythm = (plan.acoustic_bass[index], plan.acoustic_upper[index]) if part == 'acoustic' else plan.electric[index]
result = generator.parts.build_measure(measure, measure['id'], 'accompaniment', part, previous[index], measure.get('section_label', ''), rhythm)
measures.append(result)
generator.score.reidentify(measures, [])
staff = baseline['staves'][0]
document = generator.score.make_arranged_document(baseline, staff, staff_id=staff['id'], staff_name=staff['name'], role='guitar', arrangement_part_type=part, measures=measures)
if part == 'acoustic':
generator.acoustic.prepare(document)
generator.acoustic_voicing.place(document)
generator.acoustic.combine(document)
else:
generator.guitar.place_guitar(document)
reuse_head_grips(document, references, choices)
generator.model.validate_document(document)
return document['staves'][0]['measures']
def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True, contexts=None, references=(), stage='elaborated', enclosures_enabled=True):
"""Compose once per song, replacing only already-placed solo section windows."""
analysis = generator.solo.analysis.analyze(source)
sections = regions(source)
source_measures = generator.score.source_staff(source)['measures']
head_first, head_count, _, _ = sections[2]
head = parts['electric']['staves'][0]['measures'][head_first:head_first + head_count]
reports = []
requests = {}
for section_index, part in ((3, 'acoustic'), (4, 'electric')):
@@ -115,17 +66,21 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
foreground = generator.solo.notation.measures(template, solo.notes(), 'solo_' + part)
generator.solo.notation.replace_region(parts[part], first, foreground)
annotate(parts[part], solo, offset)
# The other guitarist responds to the actual foreground, including vocal return.
if cutoff < length:
vocal = parts['vocal']['staves'][0]['measures'][first:first + count]
time = fractions.Fraction()
for measure, voice_measure in zip(foreground, vocal):
returning = [copy.deepcopy(event) | {'voice': 'vocal_return'} for event in generator.score.measure_notes(voice_measure) if event['pitches'] and time + generator.model.fraction(event['onset']) >= cutoff]
if returning:
measure['voices'].append({'id': 'vocal_return', 'notes': returning})
time += generator.model.fraction(measure['duration'])
accompaniment = backing(parts[other], foreground, other, rhythms, head, solo.choices, initial)
generator.solo.notation.replace_region(parts[other], first, accompaniment)
# The other instrument already contains its canonical accompaniment.
# Installing a solo must never rewrite the learned supporting part.
# Canonical supporting notes must remain compatible throughout every
# overlapped mapped span, independent of the foreground realization.
elapsed = fractions.Fraction()
for measure in parts[other]['staves'][0]['measures'][first:first+count]:
for event in generator.score.measure_notes(measure):
start = elapsed + generator.model.fraction(event['onset'])
end = min(cutoff, start + generator.model.fraction(event['duration']))
if start >= end: continue
allowed = generator.solo.types.permitted(solo.choices,start,end)
if any(int(generator.model.pitch_number(p)) % 12 not in allowed for p in event['pitches']):
raise ValueError(f'{other} accompaniment, measure {measure["number"]}: canonical voicing outside solo map')
elapsed += generator.model.fraction(measure['duration'])
solo.diagnostics['canonical_accompaniment'] = generator.arrange.accompaniment.audit(source, parts)
reports.append(generator.solo.report.describe(solo, scales, first, offset))
for document in parts.values():
generator.model.validate_document(document)
+56 -9
View File
@@ -125,7 +125,7 @@ def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=Tr
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 - 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)
placed = generator.solo.placement.place(local, solo.tempo, profile=solo.profile)
if placed is None:
raise ValueError(f'{phrase.plan.id}: draft cannot be placed for independent audition')
foreground = generator.solo.notation.measures(view['staves'][0]['measures'], placed[1], 'draft_' + phrase.plan.id)
@@ -135,11 +135,11 @@ def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=Tr
drafts.append({'id': phrase.plan.id, 'draft_id': phrase.draft.id, 'first': begin, 'count': stop - begin, 'document': view})
song['solos'][solo.part]['draft_documents'] = drafts
stages = {}
for stage_name in ('initial', 'refined', 'before_enclosure'):
for stage_name in ('initial', 'refined', 'before_rhythm', 'before_enclosure'):
if not all(getattr(p, stage_name) for p in solo.phrases):
continue
notes = tuple(n for p in solo.phrases for n in getattr(p, stage_name).notes)
placed = generator.solo.placement.place(notes, solo.tempo)
placed = generator.solo.placement.place(notes, solo.tempo, profile=solo.profile if stage_name != 'initial' else None)
if placed is None:
raise ValueError(stage_name + ': selected theme cannot be placed')
sounded = generator.solo.types.sounding_notes(placed[1])
@@ -212,6 +212,38 @@ def mix(documents, tempo, root, cache):
output[frame] += gain * release * values[position] / 32767
return output
def register_control(baseline,song,part):
"""Whole-phrase octave transfers of the preserved baseline, never new tunes."""
from generator.solo import profiles,types,placement,notation
detail=song['solos'][part]
raw=detail['structure'].get('instrument_profile')
if not raw:return None
profile=profiles.Instrument(raw['part'],tuple(raw['strings']),raw['reference'],tuple(raw['melody_range']))
result=[]
for phrase in baseline['solos'][part]['structure']['phrases']:
notes=[]
for n in phrase['draft']['notes']:
p=n['pitch']
notes.append(types.Note(fractions.Fraction(n['start']),fractions.Fraction(n['end']),
types.Pitch(p['letter'],fractions.Fraction(p['alter']),p['octave']),tuple(n['sources']),n['role'],
continuation=n['continuation'],event_id=n['event_id']))
options=[]
for octave in range(-2,3):
moved=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=n.pitch.octave+octave)) for n in notes)
if not all(profile.permits(n.pitch.number()) for n in moved):continue
placed=placement.place(moved,fractions.Fraction(detail['tempo']),result[-1] if result else None,profile)
if placed:
rank=profile.cost(profiles.weighted_center(moved))+.02*abs(octave)+.001*placed[0]/max(1,len(moved))
options.append((rank,octave,placed[1]))
if not options:raise ValueError(part+': no whole-phrase register control within the instrument limits')
result.extend(min(options,key=lambda x:x[:2])[2])
view=excerpt(song['parts'][part],detail['first'],detail['count'])
notation.replace_region(view,0,notation.measures(view['staves'][0]['measures'],types.sounding_notes(result),'register_control_'+part))
generator.model.validate_document(view)
generator.fretboard.validate_document(view)
return view
def render(root):
stages = ('baseline', 'phrase', 'revised') if (root / 'revised' / 'snapshot.json').exists() else ('before', 'after') if (root / 'before' / 'snapshot.json').exists() else STAGES
snapshots = {stage: json.loads((root / stage / 'snapshot.json').read_text()) for stage in stages}
@@ -220,6 +252,7 @@ def render(root):
manifest['stages'][stage] = {k: v for k, v in snapshot.items() if k != 'songs'}
for song_name, song in snapshot['songs'].items():
baseline = snapshots[stages[0]]['songs'][song_name]
comparison = snapshots[stages[-1]]['songs'][song_name]
if song['source_sha256'] != baseline['source_sha256']:
raise ValueError(f'{song_name}: source changed between audition stages')
for part, solo in song['solos'].items():
@@ -234,7 +267,7 @@ def render(root):
if 'structure' in solo:
prefix.with_suffix('.json').write_text(json.dumps(solo['structure'], indent=2) + '\n')
for responsive in [False, True] if stage == stages[-1] else [False]:
backing = excerpt((song if responsive else baseline)['parts'][other], first, count)
backing = excerpt((song if responsive else comparison)['parts'][other], first, count)
vocal = excerpt(baseline['parts']['vocal'], first, count)
path = prefix.with_name(prefix.name + ('_responsive' if responsive else '_fixed')).with_suffix('.wav')
samples = mix((foreground, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)
@@ -244,7 +277,7 @@ def render(root):
strict = excerpt(solo['lead_stages']['before_enclosure']['document'], offset, count)
strict_prefix=prefix.with_name(prefix.name+'_strict')
generator.musicxml_writer.write_musicxml(strict_prefix.with_suffix('.musicxml'),strict)
backing=excerpt(baseline['parts'][other],first,count)
backing=excerpt(comparison['parts'][other],first,count)
vocal=excerpt(baseline['parts']['vocal'],first,count)
path=strict_prefix.with_name(strict_prefix.name+'_fixed').with_suffix('.wav')
clips.append((path,mix((strict,backing,vocal),fractions.Fraction(solo['tempo']),root,cache)))
@@ -257,7 +290,7 @@ def render(root):
plain = excerpt(solo['lead_stages']['refined']['document'], offset, count)
plain_prefix = prefix.with_name(prefix.name + '_plain')
generator.musicxml_writer.write_musicxml(plain_prefix.with_suffix('.musicxml'), plain)
backing = excerpt(baseline['parts'][other], first, count)
backing = excerpt(comparison['parts'][other], first, count)
vocal = excerpt(baseline['parts']['vocal'], first, count)
path = plain_prefix.with_name(plain_prefix.name + '_fixed').with_suffix('.wav')
clips.append((path, mix((plain, backing, vocal), fractions.Fraction(solo['tempo']), root, cache)))
@@ -267,11 +300,21 @@ def render(root):
path = prefix.with_name(prefix.name + '_' + kind + '_solo').with_suffix('.wav')
clips.append((path, mix((document,), fractions.Fraction(solo['tempo']), root, cache)))
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': kind + '_unaccompanied', 'window': label})
if stage == 'after':
for control, document in (('register_control', register_control(baseline,song,part)),
('before_rhythm',solo.get('lead_stages',{}).get('before_rhythm',{}).get('document'))):
if document is None: continue
prefix=root/stage/f'{song_name}_{part}_{control}'
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'),document)
backing=excerpt(comparison['parts'][other],solo['first'],solo['count'])
path=prefix.with_suffix('.wav')
clips.append((path,mix((document,backing),fractions.Fraction(solo['tempo']),root,cache)))
manifest['clips'].append({'path':path.relative_to(root).as_posix(),'kind':control,'window':'full'})
for draft in solo.get('draft_documents', ()):
prefix = root / stage / f'{song_name}_{part}_draft_{draft["id"]}'
generator.musicxml_writer.write_musicxml(prefix.with_suffix('.musicxml'), draft['document'])
if draft['first'] < 8:
backing = excerpt(baseline['parts'][other], solo['first'] + draft['first'], draft['count'])
backing = excerpt(comparison['parts'][other], solo['first'] + draft['first'], draft['count'])
path = prefix.with_suffix('.wav')
clips.append((path, mix((draft['document'], backing), fractions.Fraction(solo['tempo']), root, cache)))
manifest['clips'].append({'path': path.relative_to(root).as_posix(), 'kind': 'simple_phrase_draft', 'draft_id': draft['draft_id'], 'first': draft['first'], 'count': draft['count']})
@@ -334,16 +377,20 @@ def write_index(root, snapshots):
(root / 'index.html').write_text(''.join(body) + '</html>\n')
def write_passage_index(root, snapshots):
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Connected solo passages</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:.75rem;border-bottom:1px solid #ccd1c9}audio{width:260px}small{display:block;margin-top:.5rem}</style><h1>Connected solo passages</h1><p>Before and after use the same source songs, scale-selection settings, activity targets, tempo and root seed inputs. Fixed comparisons retain the original backing. Responsive comparisons include accompaniment recomposed around the new foreground. The returning vocal pickup and one shared mix gain are preserved.</p><p>Listen for a convincing settlement on the current chord, an idea developing across several bars, intermediate arrivals that continue, and a prepared ending. Metrics alone do not establish musical improvement. <a href="manifest.json">Fingerprints and diagnostics</a>.</p>']
body = ['<!doctype html><html lang="en"><meta charset="utf-8"><title>Connected solo passages</title><style>body{font:16px system-ui;max-width:1250px;margin:3rem auto;padding:0 1rem;background:#faf9f5;color:#202820}table{border-collapse:collapse;width:100%;margin:1rem 0 3rem}td,th{text-align:left;padding:.75rem;border-bottom:1px solid #ccd1c9}audio{width:260px}small{display:block;margin-top:.5rem}</style><h1>Connected solo passages</h1><p>Before and after use the same source songs, scale-selection settings, activity targets, tempo and root seed inputs. All fixed comparisons use the final canonical backing. The published arrangement reuses that same learned accompaniment throughout the form. The returning vocal pickup and one shared mix gain are preserved.</p><p>Listen for a convincing settlement on the current chord, an idea developing across several bars, intermediate arrivals that continue, and a prepared ending. Metrics alone do not establish musical improvement. <a href="manifest.json">Fingerprints and diagnostics</a>.</p>']
for name, song in snapshots['after']['songs'].items():
for part in song['solos']:
body.append('<h2>' + html.escape(name.replace('_', ' ').title() + ' — ' + part.title()) + '</h2>')
for stage in ('before', 'after'):
metric = snapshots[stage]['songs'][name]['solos'][part]['metrics']
body.append(f"<p>{stage.title()}: {metric['attacks_per_four_beats']:.2f} attacks / four beats; {metric['sounding_occupancy']:.1%} occupancy; longest unplanned gap {metric['longest_unplanned_gap_beats']:g} beats.</p>")
for control,label in (('register_control','Original phrases with register/string changes only'),('before_rhythm','Selected strict theme before rhythmic revision')):
if control=='before_rhythm' and control not in song['solos'][part].get('lead_stages',{}):continue
prefix=f'after/{name}_{part}_{control}'
body.append(f'<p>{label}: <audio controls preload="none" src="{prefix}.wav"></audio> <a href="{prefix}.musicxml">Notation</a></p>')
strict_available='before_enclosure' in song['solos'][part].get('lead_stages',{})
plain_available='refined' in song['solos'][part].get('lead_stages',{})
body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>After, responsive backing</th>' + ('<th>Same selected solo, without enclosures</th>' if strict_available else '') + ('<th>Plain theme, fixed backing</th>' if plain_available else '') + '</tr>')
body.append('<table><tr><th>Passage</th><th>Before, fixed backing</th><th>After, fixed backing</th><th>Final canonical arrangement</th>' + ('<th>Same selected solo, without enclosures</th>' if strict_available else '') + ('<th>Plain theme, fixed backing</th>' if plain_available else '') + '</tr>')
for window in ('full', 'opening8', 'closing4'):
body.append(f'<tr><th>{window}</th>')
for stage, mix_name in (('before', 'fixed'), ('after', 'fixed'), ('after', 'responsive')):
+3 -1
View File
@@ -5,6 +5,7 @@ import fractions
import time
import generator.solo.enclosures
import generator.solo.profiles
import generator.fretboard
import generator.harmony
@@ -129,10 +130,11 @@ def choose_phrases(plans, realize, revise_pair=None):
def validate(solo):
previous = None
profile = solo.profile or generator.solo.profiles.for_part(solo.part)
for note in solo.notes():
if note.start < 0 or note.end > solo.end or note.end <= note.start or previous and previous.end > note.start:
raise ValueError(f'{solo.part} solo: overlapping or out-of-bounds note at {note.start}')
if note.position not in generator.fretboard.single_positions_for_pitch(fractions.Fraction(note.pitch.number())):
if note.position not in generator.solo.placement.positions(note.pitch.number(), profile):
raise ValueError(f'{solo.part} solo: invalid guitar placement at {note.start}')
previous = note
for phrase in solo.phrases:
+37 -25
View File
@@ -34,7 +34,7 @@ def approach(target, harmony):
letter=letters[(index-1)%7]; octave=target.pitch.octave-(index==0)
alter=number-1-generator.model.NATURAL[letter]-12*(octave+1)
lower=types.Pitch(letter,F(alter),octave)
if not all(placement.positions(p.number()) for p in (upper,lower)):return None
if not all(placement.positions(p.number(),harmony.profile) for p in (upper,lower)):return None
identity=target.event_id+':enclosure'
return (types.Note(t-1,t-F(1,2),upper,target.sources,'enclosure_upper',event_id=identity+':upper'),
types.Note(t-F(1,2),t,lower,target.sources,'enclosure_lower',event_id=identity+':lower'))
@@ -113,28 +113,40 @@ def candidates(phrase,harmony,library,tempo,rejections):
for begin,replacement,route in proposals[:1+policy.DEFAULT.enclosure_routes]:
actual=replace_window(notes,begin,target.start,replacement)
if actual is None:continue
valid,error=drafts.contract_valid(draft,actual)
if not valid:continue
checked=harmony.evaluate(actual,phrase.state.treatments if phrase.state else (),until=phrase.plan.end,complete=True,enclosures=(contract,))
if not checked.valid:continue
valid,error=harmonic.anchors_valid(phrase.anchors,actual,phrase.plan.end,complete=True,closure=phrase.plan.closure)
if not valid:continue
placed=placement.place(actual,tempo)
if placed is None:continue
old_travel=placement.place(notes,tempo)[0]
delta=.6*(movement(actual)-movement(notes))+.025*(placed[0]-old_travel)/max(1,len(notes))/2000
density=4*len(actual)/float(phrase.plan.end-phrase.plan.start)
before_density=4*len(notes)/float(phrase.plan.end-phrase.plan.start)
delta+=.1*(max(0,density-6)-max(0,before_density-6))
transforms=tuple(dataclasses.replace(t,events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end)) for t in draft.transformations)
changed=dataclasses.replace(draft,notes=actual,transformations=transforms,enclosures=(contract,))
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in actual if d.start<=n.start<d.end)) for d in phrase.decisions)
if tuple(n for d in decisions for n in d.notes)!=actual:continue
retained=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
result.append((delta,dataclasses.replace(phrase,draft=changed,decisions=decisions,before_enclosure=draft,
routes=(*retained,*((route,) if route else ())))))
accepted+=1
variants=[(actual,contract)]
upper_index=next((i for i,n in enumerate(actual) if n.event_id==upper.event_id),0)
if upper_index:
prior=actual[upper_index-1]
if prior.end==upper.start and prior.pitch==upper.pitch and editable(draft,prior.start,upper.start):
shared=dataclasses.replace(prior,end=upper.end)
combined=(*actual[:upper_index-1],shared,*actual[upper_index+1:])
variants.append((combined,dataclasses.replace(contract,upper=shared,approach_start=upper.start)))
for actual,actual_contract in variants:
valid,error=drafts.contract_valid(draft,actual)
if not valid:continue
checked=harmony.evaluate(actual,phrase.state.treatments if phrase.state else (),until=phrase.plan.end,complete=True,enclosures=(actual_contract,))
if not checked.valid:continue
valid,error=harmonic.anchors_valid(phrase.anchors,actual,phrase.plan.end,complete=True,closure=phrase.plan.closure)
if not valid:continue
placed=placement.place(actual,tempo,profile=harmony.profile)
if placed is None:continue
old_travel=placement.place(notes,tempo,profile=harmony.profile)[0]
delta=.6*(movement(actual)-movement(notes))+.025*(placed[0]-old_travel)/max(1,len(notes))/2000
density=4*len(actual)/float(phrase.plan.end-phrase.plan.start)
before_density=4*len(notes)/float(phrase.plan.end-phrase.plan.start)
repeated=sum(a.pitch.number()==b.pitch.number() and a.end==b.start for a,b in zip(actual,actual[1:]))
old_repeated=sum(a.pitch.number()==b.pitch.number() and a.end==b.start for a,b in zip(notes,notes[1:]))
delta+=.15*max(0,repeated-old_repeated)
delta+=.1*(max(0,density-6)-max(0,before_density-6))
transforms=tuple(dataclasses.replace(t,events=tuple(n.event_id for n in actual if t.start<=n.start<t.end),
rhythm=tuple((n.start-t.start,n.end-t.start) for n in actual if t.start<=n.start<t.end)) for t in draft.transformations)
changed=dataclasses.replace(draft,notes=actual,transformations=transforms,enclosures=(actual_contract,))
decisions=tuple(dataclasses.replace(d,notes=tuple(n for n in actual if d.start<=n.start<d.end)) for d in phrase.decisions)
if tuple(n for d in decisions for n in d.notes)!=actual:continue
retained=tuple(r for r in phrase.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in actual) for n in r.notes))
result.append((delta,dataclasses.replace(phrase,draft=changed,decisions=decisions,before_enclosure=draft,
routes=(*retained,*((route,) if route else ())))))
accepted+=1
if not accepted:rejections.append({'target':target.event_id,'reason':'no valid complete enclosure within local route budget'})
return tuple(result)
@@ -152,7 +164,7 @@ def strict(solo):
def refine(solo,library,enabled=True):
started=time.monotonic();harmony=harmonic.Harmony(solo.choices);rejections=[]
started=time.monotonic();harmony=harmonic.Harmony(solo.choices,solo.profile);rejections=[]
baseline=[]
for phrase in solo.phrases:
draft=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes)) if phrase.draft else None
@@ -179,7 +191,7 @@ def refine(solo,library,enabled=True):
_,phrases,arrivals=min(states,key=lambda x:(x[0]+sparse(len(x[2])),policy.stable_rank('enclosure-selection',x[2])))
# Resolve the complete guitar path once, including both sides of phrase joins.
raw=tuple(n for p in phrases for d in p.decisions for n in d.notes)
placed=placement.place(raw,solo.tempo)
placed=placement.place(raw,solo.tempo,profile=harmony.profile)
if placed is None:raise ValueError('selected enclosure solo has no complete guitar path')
by_id={n.event_id:n for n in placed[1]};final=[]
for phrase in phrases:
+52
View File
@@ -0,0 +1,52 @@
"""Bounded timing revisions of complete realized gestures, with fixed pitches."""
from __future__ import annotations
import dataclasses
from fractions import Fraction as F
from generator.solo import drafts, harmonic, types
def fragmentation(notes):
isolated = sum(n.end-n.start <= F(1,2) and a.end < n.start and n.end < b.start
for a,n,b in zip(notes, notes[1:], notes[2:]))
gaps = sum(0 < b.start-a.end <= 1 for a,b in zip(notes, notes[1:]))
return isolated + .1*gaps
def revise(draft, harmony, treatments=()):
notes=draft.notes; edits=[]
if len(notes)<3: return draft, ()
protected=set(draft.protected)|set(draft.structural)|{n.event_id for n in notes if n.role=='quote'}
# An identifying rhythm also protects its internal rests.
protected.update(e for feature in draft.features if 'rhythm' in feature.preserves for e in feature.events)
for _ in range(2):
proposals=[]
for i,(a,n,b) in enumerate(zip(notes,notes[1:],notes[2:]),1):
if n.event_id in protected or n.end-n.start>1: continue
if not (a.end<n.start or n.end<b.start): continue
if a.event_id not in protected and 0<n.start-a.end<=1:
proposals.append((*notes[:i-1],dataclasses.replace(a,end=n.start),*notes[i:]))
if 0<b.start-n.end<=1:
proposals.append((*notes[:i],dataclasses.replace(n,end=b.start),*notes[i+1:]))
for delta in (F(-1,2),F(1,2)):
if a.end<=n.start+delta and n.end+delta<=b.start:
proposals.append((*notes[:i],dataclasses.replace(n,start=n.start+delta,end=n.end+delta),*notes[i+1:]))
if len(proposals)>=8: break
valid=[]
for actual in proposals[:8]:
if fragmentation(actual)>=fragmentation(notes): continue
if not drafts.contract_valid(draft,actual)[0]: continue
if not harmony.evaluate(actual,treatments,until=draft.end,complete=True).valid: continue
if not harmonic.anchors_valid(draft.anchors,actual,draft.end,complete=True,closure='closed')[0]: continue
valid.append(actual)
if not valid: break
actual=min(valid,key=lambda ns:(fragmentation(ns),tuple((n.start,n.end,n.pitch.number()) for n in ns)))
changed=[(a,b) for a,b in zip(notes,actual) if (a.start,a.end)!=(b.start,b.end)]
edits.append(types.DraftEdit('connected_rhythm',min(min(a.start,b.start) for a,b in changed),
max(max(a.end,b.end) for a,b in changed),tuple(a.event_id for a,b in changed),
tuple(b.event_id for a,b in changed),'same pitch sequence and attack count; connect an editable gesture'))
notes=actual
transforms=tuple(dataclasses.replace(t,rhythm=tuple((n.start-t.start,n.end-t.start) for n in notes if n.event_id in t.events))
if not t.preserves else t for t in draft.transformations)
return dataclasses.replace(draft,notes=notes,transformations=transforms),tuple(edits)
+7 -3
View File
@@ -49,7 +49,9 @@ class Result:
error: str = ''
class Harmony:
def __init__(self, choices):
def __init__(self, choices, profile=None):
from generator.solo.profiles import UNRESTRICTED
self.profile = profile or UNRESTRICTED
self.choices = choices
self.spans = tuple(Span(c.span.start, c.span.end, roles(c.span.chord), c.scale, generator.harmony.symbol(c.span.chord) if c.span.chord else 'N.C.') for c in choices)
self._at = {}
@@ -110,7 +112,8 @@ class Harmony:
actual = events.get(expected.event_id)
if not actual or (actual.start, actual.end, actual.pitch) != (expected.start, expected.end, expected.pitch):
return {}, contract.id + ': missing or changed enclosure event ' + expected.event_id
if upper.end-upper.start != fractions.Fraction(1,2) or lower.end-lower.start != fractions.Fraction(1,2) or upper.end != lower.start or lower.end != target.start:
approach_start = contract.approach_start if contract.approach_start is not None else upper.start
if not upper.start <= approach_start or upper.end-approach_start != fractions.Fraction(1,2) or lower.end-lower.start != fractions.Fraction(1,2) or upper.end != lower.start or lower.end != target.start:
return {}, contract.id + ': enclosure must be two contiguous eighths immediately before its target'
if not 0 < upper.pitch.number()-target.pitch.number() <= 3 or lower.pitch.number() != target.pitch.number()-1:
return {}, contract.id + ': enclosure does not surround the exact target register'
@@ -192,7 +195,8 @@ class Harmony:
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)
if function == 'basic' or note.role == 'quote' or enclosure:
brief_enclosure = enclosure and (exception or enclosure.approach_start is None or enclosure.approach_start == note.start)
if function == 'basic' or note.role == 'quote' or brief_enclosure:
continue
if duration >= generator.solo.policy.DEFAULT.long_note and contract is None:
return Result(False, 0, error=f'{note.pitch.label()} at {start}-{end} over {span.chord}: sustained {function} without a planned treatment')
+7 -7
View File
@@ -10,7 +10,7 @@ import fractions
import generator.model
import generator.fretboard
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, references
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, references, profiles
F = fractions.Fraction
@@ -31,7 +31,7 @@ def available(harmony, start, end, *, members=False):
allowed &= harmony.members(start, end)
if not span or not span.scale:
return ()
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)
return tuple(p for i in range(-35, 28) if harmony.profile.register[0] <= (p := types.scale_pitch(span.scale, i)).number() <= harmony.profile.register[1] and p.number()%12 in allowed and placement.positions(p.number(),harmony.profile))
def adapt(notes, harmony):
@@ -324,7 +324,7 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
from generator.solo import realization,themes,phrasing
context.resources[id(library)]=library
context.resources[id(rhythms)]=rhythms
harmony=harmonic.Harmony(choices)
harmony=harmonic.Harmony(choices, profiles.for_part(part,analysis))
seed=policy.stable_rank('source_variations',(analysis.melody,choices,policy.SEED_NAMESPACE))
states=[(0.,())]
failures=[]
@@ -344,7 +344,7 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
meter=measure['time_signature'];break
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
for source in sources:
key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT)
key=('phrasing',source,incoming,part,end,stage,seed,id(library),id(rhythms),policy.DEFAULT,harmony.profile)
if key in context.results:
pool=context.results[key];context.diagnostics['cache_hits']+=1
else:
@@ -383,7 +383,7 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
if not finished:raise realization.SearchExhausted(f'{part}: phrase-boundary search budget exhausted; not proof of incompatibility')
return tuple((rank((cost,phrases))[0],types.Solo(part,choices,phrases,analysis.tempo,end,
{**context.diagnostics,'construction':'thematic_recomposition','policy':policy.describe()['fingerprint']},
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in finished)
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases),harmony.profile)) for cost,phrases in finished)
def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stage,mode='thematic'):
@@ -393,7 +393,7 @@ def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stag
return thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage)
context.resources[id(library)]=library
context.resources[id(rhythms)]=rhythms
harmony=harmonic.Harmony(choices)
harmony=harmonic.Harmony(choices, profiles.for_part(part,analysis))
sources=clipped_sources(analysis,end)
seed=policy.stable_rank('source_variations', (analysis.melody,choices,policy.SEED_NAMESPACE))
states=[(0.,())]
@@ -424,7 +424,7 @@ def solo_options(analysis,measures,part,end,choices,library,rhythms,context,stag
states=sequences.diverse(following,lambda v:(v[0],policy.stable_rank(seed,tuple((p.draft.id,p.state.last.position if p.state.last else None) for p in v[1]))),
lambda v:(tuple(p.draft.octave_transfer for p in v[1])),policy.DEFAULT.chorus_frontier)
return tuple((cost,types.Solo(part,choices,phrases,analysis.tempo,end,{**context.diagnostics,'construction':'source_phrase_variations','policy':policy.describe()['fingerprint']},
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases))) for cost,phrases in states)
tuple(types.MelodicPassagePlan(p.plan.passage_id,p.plan.start,p.plan.end,(p.plan.id,),seed) for p in phrases),harmony.profile)) for cost,phrases in states)
def similarity(first,second):
+38 -10
View File
@@ -7,7 +7,7 @@ from fractions import Fraction as F
import generator.model
import generator.harmony
from generator.solo import types, policy, sequences
from generator.solo import types, policy, sequences, profiles
def heard(notes, operation, anchor=None):
@@ -52,7 +52,11 @@ def repetition(figure, history, bar):
return min(1., discount*overuse/2) + .3*oscillation + .3*min(1.,cadence/2)
def register_goal(incoming, part, fallback):
def register_goal(incoming, part, fallback, profile=None):
if profile and profile.reference is not None:
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
center = round(sum(recent)/len(recent)) if recent else profile.reference
return max(profile.reference-4,min(profile.reference+4,center))
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
if not recent:
return fallback
@@ -76,7 +80,7 @@ def endpoint_cost(finish, start, end, count, harmony, bar):
degrees=generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)) if span and span.scale else {}
dominant=degrees.get(3)==0 and degrees.get(7)==-1
short_remainder=.2*max(0,4-remaining) if remaining else 0.
return pacing+.035*abs(length-5)+.16*dominant+.1*(length==8)+short_remainder
return pacing+(.08 if ending_effect(finish-F(1,100),harmony,False)=='local_settle' else 0)+.035*abs(length-5)+.16*dominant+.1*(length==8)+short_remainder
def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2):
@@ -105,11 +109,11 @@ def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2
return tuple(sorted(values,key=lambda s:(endpoint_cost(s.end,start,end,incoming.closures,harmony,bar),s.end))[:limit])
def endings(source, incoming, part, harmony, bar, *, limit=8):
def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
"""Choose pitch AND sounding interval before generating the approach."""
from generator.solo.leads import available
base=incoming.last.pitch.number() if incoming.last else source.notes[0].pitch.number() if source.notes else 55
goal=register_goal(incoming,part,base)
goal=register_goal(incoming,part,base,harmony.profile)
values=[]
duration=source.end-source.start
holds=(F(2),F(5,2),F(3),F(7,2),F(4)) if duration>=4 else (min(F(2),duration/2),)
@@ -120,10 +124,10 @@ def endings(source, incoming, part, harmony, bar, *, limit=8):
for pitch in available(harmony,arrival,arrival+hold,members=True):
if abs(pitch.number()-goal)>12:continue
anchor=types.Anchor(f'{source.id}:close:{arrival}:{pitch.number()}',arrival,arrival,pitch,
harmony.members(arrival,arrival+hold),(41,76),hold,'terminal',harmony.at(arrival).chord,
hold_until=arrival+hold)
harmony.members(arrival,arrival+hold),harmony.profile.register,hold,'terminal',harmony.at(arrival).chord,
hold_until=arrival+hold,effect=ending_effect(arrival,harmony,final))
cadence=sum(h.ending==(pitch.number()%12,arrival%4,hold) for h in incoming.history)
rank=destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release)
rank=harmony.profile.cost(pitch.number())+destination_cost(pitch,arrival,arrival+hold,harmony,incoming,goal,True)+.16*min(2,cadence)+.035*abs(float(hold)-3)+.015*float(release)
values.append((rank,anchor))
selected=destination_options(values,lambda x:(x[0],policy.stable_rank(source.id,x[1])),
lambda x:x[1].pitch,lambda x:x[1].earliest,harmony,limit)
@@ -158,7 +162,7 @@ def evaluate(notes, incoming, harmony, start, end, bar):
entry=max(0,abs(notes[0].pitch.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(notes,notes[1:]))/max(1,len(notes)-1)
history_cost=repetition(heard(notes,'develop'),incoming.history,bar)
return 2*checked.exposure+entry+leaps+.35*max(0,4-density,density-6)+.8*history_cost
return harmony.profile.cost(profiles.weighted_center(notes))+2*checked.exposure+entry+leaps+.35*max(0,4-density,density-6)+.8*history_cost
@functools.lru_cache(maxsize=512)
@@ -197,7 +201,8 @@ def destination_cost(pitch, start, end, harmony, incoming=None, goal=None, termi
if incoming and incoming.home and len(incoming.home.notes)>1 and incoming.last:
motion=incoming.home.notes[-1].pitch.number()-incoming.home.notes[0].pitch.number()
direction=.025 if (number-incoming.last.pitch.number())*motion<0 else 0.
return function+register+entry+recurrence+lookahead+direction
pending=.08 if incoming and incoming.next_destination and pc not in incoming.next_destination else 0.
return function+register+entry+recurrence+lookahead+direction+pending
def destination_family(pitch, time, harmony):
@@ -217,3 +222,26 @@ def destination_options(values, rank, pitch, time, harmony, limit):
if value not in selected:selected.append(value)
if len(selected)==limit:break
return tuple(selected)
def ending_effect(time, harmony, final=False):
if final: return 'handoff'
current=harmony.at(time)
previous=next((s for s in reversed(harmony.spans) if current and s.end<=current.start and s.chord!=current.chord and s.scale),None)
if not current or not current.scale or not previous: return 'local_settle'
old=generator.harmony.parse_symbol(previous.chord)
new=generator.harmony.parse_symbol(current.chord)
degrees=generator.harmony.degrees(old)
def root(chord):
p=chord['root'];return int(generator.model.NATURAL[p['letter']]+generator.model.alteration(p['alter']))%12
if degrees.get(3)==0 and degrees.get(7)==-1 and (root(old)-root(new))%12==7:
return 'cadential_arrival'
return 'local_settle'
def next_destination(anchor,harmony):
if anchor.effect!='local_settle': return ()
following=[s for s in harmony.spans if s.start>=anchor.hold_until and s.scale]
resolution=next((s for s in following[:2] if ending_effect(s.start,harmony)=='cadential_arrival'),None)
selected=resolution or next(iter(following),None)
return tuple(sorted(selected.roles.basic & selected.scale.pitches)) if selected else ()
+9 -7
View File
@@ -6,6 +6,7 @@ import functools
import generator.fretboard
import generator.solo.types
from generator.solo.profiles import UNRESTRICTED
def travel(previous: generator.solo.types.Note | None, current: generator.solo.types.Note, tempo: fractions.Fraction) -> int:
if previous is None:
@@ -33,12 +34,12 @@ def stable_path(path):
@functools.lru_cache(maxsize=4096)
def alternatives(notes, tempo, previous=None, limit=2):
def alternatives(notes, tempo, previous=None, limit=2, profile=UNRESTRICTED):
"""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 = {}
for position in positions(note.pitch.number()):
for position in positions(note.pitch.number(), profile):
current = dataclasses.replace(note, position=position)
for hand in hand_positions(position):
best = None
@@ -73,10 +74,11 @@ def alternatives(notes, tempo, previous=None, limit=2):
@functools.lru_cache(maxsize=4096)
def place(notes, tempo, previous=None):
result = alternatives(notes,tempo,previous,1)
def place(notes, tempo, previous=None, profile=UNRESTRICTED):
result = alternatives(notes,tempo,previous,1,profile)
return result[0] if result else None
@functools.lru_cache(maxsize=128)
def positions(number: int):
return generator.fretboard.single_positions_for_pitch(fractions.Fraction(number))
@functools.lru_cache(maxsize=2048)
def positions(number: int, profile=UNRESTRICTED):
profile = profile or UNRESTRICTED
return tuple(p for p in generator.fretboard.single_positions_for_pitch(fractions.Fraction(number)) if p.string_number in profile.strings)
+1 -1
View File
@@ -52,7 +52,7 @@ class Policy:
enclosure_routes: int = 4
enclosure_frontier: int = 8
enclosure_max_per_32_bars: int = 4
composition_version: str = 'chord-destinations-enclosures-1'
composition_version: str = 'six-parts-register-signatures-1'
# Preserve the former default root seeds while decoupling them from policy edits.
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
+55
View File
@@ -0,0 +1,55 @@
"""Sounding-register references and hard solo instrument domains."""
from __future__ import annotations
import dataclasses
from fractions import Fraction as F
import generator.fretboard
@dataclasses.dataclass(frozen=True)
class Instrument:
part: str = ''
strings: tuple[int, ...] = (1, 2, 3, 4, 5, 6)
reference: int | None = None
melody_range: tuple[int, int] | None = None
@property
def register(self):
tuning = [p for s, p in generator.fretboard.STANDARD_TUNING if s in self.strings]
return min(tuning) + 1, max(tuning) + 12
def permits(self, number):
return any(p.string_number in self.strings for p in generator.fretboard.single_positions_for_pitch(F(number)))
def cost(self, number):
return min(1., max(0, abs(number - self.reference) - 4) / 12) if self.reference is not None else 0.
UNRESTRICTED = Instrument()
def weighted_center(notes):
values = sorted((n.pitch.number(), n.end - n.start) for n in notes)
total = sum((d for _, d in values), F())
elapsed = F()
for number, duration in values:
elapsed += duration
if elapsed >= total / 2:
return number
return 60
def for_part(part, analysis=None):
if part not in ('acoustic', 'electric'):
raise ValueError('unknown solo instrument: ' + part)
strings = (2, 3, 4, 5, 6) if part == 'acoustic' else (1, 2, 3, 4)
if analysis is None or not analysis.melody:
return Instrument(part, strings)
# Analysis contains the prepared concert source. Electric melody is written
# two octaves up and sounds one down (parts.build_measure), hence +12.
# Use the source voice, never the highest added harmonization voice.
shift = 12 if part == 'electric' else 0
notes = tuple(n for n in analysis.melody if n.end > analysis.body_offset)
return Instrument(part, strings, weighted_center(notes) + shift,
(min(n.pitch.number() for n in notes) + shift, max(n.pitch.number() for n in notes) + shift))
+1 -1
View File
@@ -501,7 +501,7 @@ def checked_phrase(plan, draft, notes, contracts, incoming, harmony, tempo, edit
valid, _ = generator.solo.drafts.contract_valid(draft, notes)
if not valid:
return None
placed = generator.solo.placement.place(notes, tempo, incoming.last)
placed = generator.solo.placement.place(notes, tempo, incoming.last, harmony.profile)
if placed is None:
return None
travel, notes = placed
+5 -4
View File
@@ -14,6 +14,7 @@ import generator.solo.harmonic
import generator.solo.drafts
import generator.solo.similarity
import generator.solo.references
import generator.solo.placement
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')
@@ -58,7 +59,7 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
lines.append(f' Destination: {plan.target.chord}; {plan.target.reason}; arrival {plan.target.earliest}-{plan.target.latest}')
for anchor in phrase.anchors:
actual = generator.solo.harmonic.anchor_note(anchor, phrase.state.notes if phrase.state else solo.notes())
lines.append(f' Anchor {anchor.id}: {anchor.role}; {anchor.chord}; {anchor.pitch.label()}; window {anchor.earliest}-{anchor.latest}; hold {anchor.duration}; hold until {anchor.hold_until}; completed {actual is not None}')
lines.append(f' Anchor {anchor.id}: {anchor.role}/{anchor.effect}; {anchor.chord}; {anchor.pitch.label()}; window {anchor.earliest}-{anchor.latest}; hold {anchor.duration}; hold until {anchor.hold_until}; completed {actual is not None}')
if phrase.state:
valid, reason = generator.solo.harmonic.anchors_valid(phrase.anchors, phrase.state.notes, phrase.plan.end, complete=phrase.plan.end == phrase.plan.passage_end, closure=phrase.plan.closure)
lines.append(f' Ending contract fulfilled: {valid}; {reason}')
@@ -124,7 +125,7 @@ def structure(solo):
entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home)
entry['score'] = phrase.score
phrases.append(entry)
return {'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures), '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 {'instrument_profile': rational(dataclasses.asdict(solo.profile)) if solo.profile else None, 'phrasing': phrasing_report(solo), 'source_comparison': {stage:generator.solo.similarity.compare(tuple(n for p in solo.phrases for n in (p.refined.notes if stage=='plain' and p.refined else tuple(n for d in p.decisions for n in d.notes))),tuple(n for p in solo.phrases if p.initial for n in p.initial.notes)) for stage in ('plain','elaborated')}, 'fretboard': fretboard_report(solo), 'passages': [{'id': p.id, 'start': str(p.start), 'end': str(p.end), 'phrases': list(p.phrase_ids), 'seed': p.seed} for p in solo.passages], 'phrases': phrases, 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures), 'harmonic_exposure': checked.exposure, 'harmonic_valid': checked.valid, 'harmonic_error': checked.error, 'unresolved_treatments': rational([dataclasses.asdict(t) for t in checked.pending]), 'treatments': rational([dataclasses.asdict(t) for t in contracts])}
def phrasing_report(solo):
@@ -146,7 +147,7 @@ def phrasing_report(solo):
if anchor.role!='terminal' or phrase.plan.closure!='closed':continue
note=generator.solo.harmonic.anchor_note(anchor,phrase.state.notes)
endings.append({'phrase':phrase.plan.id,'arrival':str(note.start) if note else None,'pitch':anchor.pitch.label(),
'required_beats':str(anchor.duration),'sounding_beats':str(note.end-note.start) if note else None,
'effect':anchor.effect,'required_beats':str(anchor.duration),'sounding_beats':str(note.end-note.start) if note else None,
'release':str(phrase.plan.release),'completed':note is not None})
if local:
regions.append({'start':str(phrase.plan.start),'end':str(phrase.plan.end),
@@ -180,7 +181,7 @@ def fretboard_report(solo):
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
if len(generator.solo.placement.positions(note.pitch.number(),solo.profile))==1:forced+=1
else:optional+=1
total=sum(durations.values())
return {'attacks_by_string':dict(sorted(attacks.items())),
+10 -6
View File
@@ -33,7 +33,7 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
harmony.route_libraries={}
harmony.concrete_routes={}
harmony.route_libraries[id(library)]=library
concrete_key=(notes,frozenset(fixed),id(library),register)
concrete_key=(notes,frozenset(fixed),id(library),register,harmony.profile)
if concrete_key in harmony.concrete_routes:
return tuple(dataclasses.replace(r,relationship=relationship) for r in harmony.concrete_routes[concrete_key])
# Event/source IDs and non-fixed placeholder pitches do not affect route
@@ -42,7 +42,7 @@ def candidates(notes, fixed, harmony, library, register, relationship='connect t
fixed_indices=frozenset(i for i,n in enumerate(notes) if n.event_id in fixed)
canonical=tuple(dataclasses.replace(n,sources=(),event_id=str(i),role='connection',position=None,
pitch=n.pitch if i==0 or i==len(notes)-1 or i in fixed_indices else types.Pitch('c',fractions.Fraction(),0)) for i,n in enumerate(notes))
key=(canonical,fixed_indices,id(library),register)
key=(canonical,fixed_indices,id(library),register,harmony.profile)
if key not in harmony.route_cache:
harmony.route_cache[key]=_candidates(canonical,{str(i) for i in fixed_indices},harmony,library,register)
result=[]
@@ -76,9 +76,9 @@ def _candidates(notes, fixed, harmony, library, register):
types.permitted(harmony.choices,n.start,n.end),
n.pitch.number() if n.event_id in fixed or i==len(notes)-1 else None) for i,n in enumerate(notes))
if not hasattr(harmony,'pitch_routes'):harmony.pitch_routes={}
key=(first.pitch,contexts,id(library),register)
key=(first.pitch,contexts,id(library),register,harmony.profile)
if key not in harmony.pitch_routes:
harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register)
harmony.pitch_routes[key]=pitch_paths(first.pitch,contexts,library,register,harmony.profile)
if len(harmony.pitch_routes)>8192:harmony.pitch_routes.pop(next(iter(harmony.pitch_routes)))
route_spans=tuple((max(first.start,c.span.start),min(destination.end,c.span.end),c.scale.label())
for c in harmony.choices if c.scale and c.span.start<destination.end and c.span.end>first.start)
@@ -91,12 +91,15 @@ def _candidates(notes, fixed, harmony, library, register):
return tuple(result)
def pitch_paths(first, contexts, library, register):
def pitch_paths(first, contexts, library, register, profile=None):
"""Time-independent pitch work; context includes every overlapped collection.
Sounding durations and chord roles are still validated on each concrete
realization. Equal collection contexts can reuse paths across rhythmic edits.
"""
from generator.solo.profiles import UNRESTRICTED
profile = profile or UNRESTRICTED
if not profile.permits(first.number()): return ()
destination=contexts[-1][2]
try:
displacement=types.scale_index(contexts[0][0],destination)-types.scale_index(contexts[0][0],first.number())
@@ -106,6 +109,7 @@ def pitch_paths(first, contexts, library, register):
direction=(destination>first.number())-(destination<first.number())
paths=dict.fromkeys(path for d in displacements for path in library.retrieve(len(contexts),d,direction))
low,high=generator.fretboard.single_register((register[0]-7,register[1]+5))
low, high = max(low, profile.register[0]), min(high, profile.register[1])
result=[]
for path in paths:
realized=[first]
@@ -116,7 +120,7 @@ def pitch_paths(first, contexts, library, register):
pitch=options[0];number=pitch.number()
if not low<=number<=high or number%12 not in allowed:break
if fixed is not None and number!=fixed:break
if not generator.solo.placement.positions(number):break
if not generator.solo.placement.positions(number,profile):break
realized.append(pitch)
if len(realized)==len(contexts):result.append((tuple(realized),path.sources))
return tuple(result)
+40 -29
View File
@@ -10,7 +10,7 @@ from fractions import Fraction as F
import generator.model
import generator.harmony
from generator.solo import types, policy, sequences, routes, references, similarity, phrasing
from generator.solo import types, policy, sequences, routes, references, similarity, phrasing, profiles
def source_notes(analysis):
@@ -22,7 +22,7 @@ def source_notes(analysis):
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(),
plan=types.PhrasePlan(part+'_'+source.id,source.start,source.end,motif,'space','thematic_recomposition',None,harmony.profile.register,F(),F(),
(types.ActivitySpan(source.start,source.end,'space',F(),F()),),passage_id=source.id,passage_start=source.start,passage_end=source.end)
draft=types.MelodicDraft(plan.id,source.start,source.end,(),(),(),(),(),incoming.home,'space',source_phrase=source,source_reference=incoming.source_reference)
phrase=realization.checked_phrase(plan,draft,(),incoming.treatments,incoming,harmony,tempo)
@@ -43,10 +43,10 @@ def short_fragment(source,incoming,part,harmony,tempo,seed,final):
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)
anchor=types.Anchor(note.event_id+':arrival',note.start,note.start,pitch,harmony.members(note.start,note.end),harmony.profile.register,note.end-note.start,'terminal' if final else 'intermediate',harmony.at(note.start).chord,hold_until=note.end)
transform=types.Transformation(note.event_id,reference[0].event_id,incoming.home.id if incoming.home else '', 'fragment',note.start,note.end,(note.event_id,),((F(),note.end-note.start),),preserves=())
draft=types.MelodicDraft(part+'_'+note.event_id,source.start,source.end,(note,),(transform,),(note.event_id,),(),(anchor,),incoming.home,'fragment',source_phrase=source,source_reference=incoming.source_reference)
plan=types.PhrasePlan(draft.id,source.start,source.end,motif,'fragment','thematic_recomposition',None,(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)
plan=types.PhrasePlan(draft.id,source.start,source.end,motif,'fragment','thematic_recomposition',None,harmony.profile.register,F(5,4),F(),ending='sustain' if final else 'connected',closure='closed' if final else 'open',passage_id=source.id,passage_start=source.start,passage_end=source.end)
phrase=realization.checked_phrase(plan,draft,(note,),incoming.treatments,incoming,harmony,tempo)
if phrase:
state=dataclasses.replace(phrase.state,home=incoming.home,source_reference=incoming.source_reference,callbacks=incoming.callbacks,
@@ -194,7 +194,7 @@ def instantiate(templates,first,steps,harmony):
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 ()
if not harmony.profile.permits(p.number()) 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 ()
@@ -222,11 +222,20 @@ def _prefix_paths(templates,ref,first,harmony,library):
result.append(dataclasses.replace(template,pitch=p))
return (tuple(result),)
paths=[]
if ref.treatment == 'return':
from generator.solo.leads import available
delta=first.number()-original[0].pitch.number()
direct=[]
for template,n in zip(templates,original):
p=next((p for p in available(harmony,template.start,template.end,members=template.end-template.start>=2) if p.number()==n.pitch.number()+delta),None)
if p is None: break
direct.append(dataclasses.replace(template,pitch=p))
if len(direct)==count: paths.append(tuple(direct))
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))
paths.extend(r.notes for r in routes.candidates(template,{template[-1].event_id},harmony,library,harmony.profile.register,'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)
@@ -247,7 +256,7 @@ class Unit:
def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,context,*,home=None,operation='introduce',terminal=False,batch=0,ending=None,goal=None):
# History is evaluated by the caller after retrieving these concrete options.
# Every state component used to construct this pool is in its cache key.
if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',harmony.choices)
if not hasattr(harmony,'fingerprint'):harmony.fingerprint=policy.stable_rank('harmony',(harmony.choices,harmony.profile))
key=('theme-unit',ref,start,finish,incoming.last,incoming.direction,incoming.treatments,incoming.history,home,operation,ending,goal,batch,harmony.fingerprint,id(library),id(rhythms),policy.DEFAULT)
if key in context.results:
context.diagnostics['cache_hits']+=1;return context.results[key]
@@ -257,14 +266,14 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
if len(pattern)<count or len(pattern)>16: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()
base=incoming.last.pitch.number() if incoming.last else harmony.profile.reference if harmony.profile.reference is not None else ref.notes[0].pitch.number()
firsts=pitches(harmony,templates[0],base,limit=6 if batch else 3)
targets=(ending.pitch,) if ending else pitches(harmony,templates[-1],goal if goal is not None else base,members=True,limit=policy.DEFAULT.theme_destinations,incoming=incoming)
for first in firsts:
for target in targets:
# Rank joint jobs, retaining rhythm and destination coverage before
# expensive path instantiation. The budget is spent on complete routes.
rank=max(0,abs(first.number()-base)-5)/12+phrasing.destination_cost(target,templates[-1].start,templates[-1].end,harmony,incoming,goal if goal is not None else base,terminal)
rank=harmony.profile.cost(first.number())+max(0,abs(first.number()-base)-5)/12+phrasing.destination_cost(target,templates[-1].start,templates[-1].end,harmony,incoming,goal if goal is not None else base,terminal)
jobs.append((rank,templates,label,first,target))
jobs.sort(key=lambda j:(j[0],policy.stable_rank(ref.id,(j[1],j[3],j[4]))))
selected=[];rhythm_seen=set();target_seen=set();entry_seen=set();degrees_seen=set()
@@ -292,7 +301,7 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
continue
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)[:3+batch]:
for route in routes.candidates(tail,{tail[-1].event_id},harmony,library,harmony.profile.register,operation+' '+ref.id)[:3+batch]:
proposed.append(((*prefix[:-1],*route.notes),(route,)))
seen=set()
for actual,connections in proposed:
@@ -309,8 +318,8 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
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)
direction=min(1,abs(last.pitch.number()-goal)/12) if goal is not None else 0.
# Activity belongs to the complete phrase; contracts are pass/fail.
score=2*checked.exposure+entry+leaps+phrasing.destination_cost(last.pitch,last.start,last.end,harmony,incoming,goal,terminal)
anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),(41,76),
score=harmony.profile.cost(profiles.weighted_center(actual))+2*checked.exposure+entry+leaps+phrasing.destination_cost(last.pitch,last.start,last.end,harmony,incoming,goal,terminal)
anchor=ending or types.Anchor(last.event_id+':arrival',last.start,last.start,last.pitch,harmony.members(last.start,last.end),harmony.profile.register,
last.end-last.start,'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}
@@ -322,17 +331,14 @@ def unit_candidates(ref,start,finish,incoming,harmony,library,rhythms,meter,cont
def home_reference(home,operation):
# Prefer a distinguishing turn/leap wherever it actually occurred. The heard
# home remains immutable; a development never becomes the new source theme.
groups=[home.notes[i:i+3] for i in range(max(1,len(home.notes)-2))]
def rank(group):
intervals=[b.pitch.number()-a.pitch.number() for a,b in zip(group,group[1:])]
turn=len(intervals)>1 and intervals[0]*intervals[1]<0
return (-int(turn),-max((abs(x) for x in intervals),default=0),group[0].start)
notes=min(groups,key=rank)
# A return refers to the opening the listener heard, not whichever later
# three-note turn happens to rank best. Development may use its smaller core.
notes = home.signature or home.notes[:min(4, len(home.notes))]
if operation not in ('return', 'repeat_with_harmonic_adaptation'):
notes = notes[:3]
origin=notes[0].start
notes=tuple(dataclasses.replace(n,start=n.start-origin,end=n.end-origin) for n in notes)
treatment='rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','fragment','return','develop_rhythm') else 'contour_seed'
treatment='return' if operation=='return' else 'rhythmic_seed' if operation in ('repeat_with_harmonic_adaptation','fragment','develop_rhythm') else 'contour_seed'
return types.SourceReference(home.id,home.parent,home.source_start,home.source_start+home.duration,notes,treatment)
@@ -341,7 +347,7 @@ def make_home(unit,harmony,start=None,end=None):
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))
return dataclasses.replace(home,degree_steps=relative_steps(unit.notes,harmony),signature=home.notes[:len(unit.reference.notes)])
def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,meter,tempo,context,seed,*,stage='elaborated',batch=0,final=False,solo_end=None):
@@ -355,7 +361,7 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
seed_key=('source-seeds',analysis,bar,seed)
if seed_key not in context.pools:context.pools[seed_key]=seeds(analysis,bar,seed)
roots=context.pools[seed_key]
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1))
ending_options=phrasing.endings(source,incoming,part,harmony,bar,limit=8*(batch+1),final=final)
states=[(0.,(),incoming.home,incoming,ending) for ending in ending_options]
cursor=source.start
while cursor<source.end and states:
@@ -447,13 +453,18 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
source_links=tuple((n.event_id,n.sources) for n in notes),sustained_tails=tails)
motif=types.Motif(home.id,home.notes,home.duration,(), 'realized solo theme from '+source_ref.id)
landmark=types.Landmark(ending.earliest,ending.latest,ending.pitch.number(),ending.pitch.number(),0,'sustained destination')
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,(41,76),F(5,4),
plan=types.PhrasePlan(identifier,source.start,source.end,motif,draft.relationship,'thematic_recomposition',None,harmony.profile.register,F(5,4),
source.end-ending.hold_until,phrasing.activity(source,ending),(landmark,), 'sustain','closed',seed,
source.id,source.start,source.end,(anchors,))
before_rhythm=draft
from generator.solo import gesture_rhythm
if stage=='elaborated':
draft,rhythm_edits=gesture_rhythm.revise(draft,harmony,incoming.treatments)
notes=draft.notes;edits=(*edits,*rhythm_edits);transforms=draft.transformations
phrase=realization.checked_phrase(plan,draft,notes,incoming.treatments,incoming,harmony,tempo,edits)
if phrase is None:continue
score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar)
score+=sum(phrasing.destination_cost(u.anchor.pitch,u.anchor.earliest,u.anchor.hold_until,harmony,incoming,phrasing.register_goal(incoming,part,u.notes[0].pitch.number()),u.anchor.role=='terminal') for u in units)/len(units)
score=phrasing.evaluate(notes,incoming,harmony,source.start,source.end,bar)+.1*gesture_rhythm.fragmentation(notes)
score+=sum(phrasing.destination_cost(u.anchor.pitch,u.anchor.earliest,u.anchor.hold_until,harmony,incoming,phrasing.register_goal(incoming,part,u.notes[0].pitch.number(),harmony.profile),u.anchor.role=='terminal') for u in units)/len(units)
appearance_state=incoming
for unit in units:
score+=phrasing.appearance_cost(unit.operation,appearance_state,unit.notes[0].start,solo_end)/len(units)
@@ -464,16 +475,16 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
for transform in transforms:
group=tuple(n for n in notes if n.event_id in transform.events)
if group:history=phrasing.remember(history,phrasing.heard(group,transform.operation,ending if group[-1].end==ending.hold_until else None),bar)
current=dataclasses.replace(phrase.state,home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units),
current=dataclasses.replace(phrase.state,next_destination=phrasing.next_destination(ending,harmony),home=home,source_reference=source_ref,callbacks=incoming.callbacks+sum(u.operation=='callback' for u in units),
history=history,returns=state.returns,closures=incoming.closures+1,register_peak=max(state.register_peak,*(n.pitch.number() for n in notes)))
if not incoming.home:
introduced=tuple(n for n in notes if n.start<units[0].notes[-1].end)
heard=dataclasses.replace(home,notes=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced),
degree_steps=relative_steps(introduced,harmony))
degree_steps=relative_steps(introduced,harmony),signature=tuple(dataclasses.replace(n,start=n.start-source.start,end=n.end-source.start,position=None) for n in introduced[:len(units[0].reference.notes)]))
current=dataclasses.replace(current,home=heard,themes=(heard,))
source_control=leads.shift(source.notes,next((o for o in (0,1,-1,2,-2) if all(41<=n.pitch.number()+12*o<=76 for n in source.notes)),0))
original=dataclasses.replace(draft,id=identifier+':source',notes=source_control,features=(),anchors=(),structural=(),sustained_tails=())
refined=dataclasses.replace(draft,id=identifier+':plain',notes=plain)
result.append(dataclasses.replace(phrase,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)))
result.append(dataclasses.replace(phrase,score=score,state=current,initial=original,refined=refined,before_rhythm=before_rhythm,routes=(*tuple(r for u in units for r in u.routes if all(any((n.event_id,n.start,n.end,n.pitch)==(a.event_id,a.start,a.end,a.pitch) for a in notes) for n in r.notes)),*selected_routes)))
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(),p.anchors[-1].duration,len(p.state.returns)),policy.DEFAULT.phrase_alternatives)
+7
View File
@@ -8,6 +8,7 @@ import generator.fretboard
import generator.model
import generator.scales.library
import generator.scales.selection
import generator.solo.profiles
@dataclasses.dataclass(frozen=True, order=True)
class Pitch:
@@ -116,6 +117,7 @@ class Anchor:
chord: str
onset_required: bool = False
hold_until: fractions.Fraction | None = None
effect: str = 'local_settle'
@dataclasses.dataclass(frozen=True)
class Gesture:
@@ -136,6 +138,7 @@ class RealizedMotif:
chord_degrees: tuple[tuple[int, ...], ...] = ()
source_start: fractions.Fraction = fractions.Fraction()
degree_steps: tuple[int, ...] = ()
signature: tuple[Note, ...] = ()
def contour(self):
return tuple(b.pitch.number() - a.pitch.number() for a, b in zip(self.notes, self.notes[1:]))
@@ -163,6 +166,7 @@ class ContinuationState:
returns: tuple[fractions.Fraction, ...] = ()
closures: int = 0
register_peak: int = 0
next_destination: tuple[int, ...] = ()
@dataclasses.dataclass(frozen=True)
class HeardFigure:
@@ -243,6 +247,7 @@ class EnclosureContract:
chord: str
target_role: str
protected_tail: SustainedTail
approach_start: fractions.Fraction | None = None
@dataclasses.dataclass(frozen=True)
class MelodicDraft:
@@ -343,6 +348,7 @@ class Phrase:
refined: MelodicDraft | None = None
routes: tuple[RouteCandidate, ...] = ()
before_enclosure: MelodicDraft | None = None
before_rhythm: MelodicDraft | None = None
@dataclasses.dataclass(frozen=True)
class Solo:
@@ -353,6 +359,7 @@ class Solo:
end: fractions.Fraction
diagnostics: dict = dataclasses.field(default_factory=dict, compare=False)
passages: tuple[MelodicPassagePlan, ...] = ()
profile: generator.solo.profiles.Instrument | None = None
def notes(self) -> tuple[Note, ...]:
return sounding_notes(n for p in self.phrases for d in p.decisions for n in d.notes)