Soloing
This commit is contained in:
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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 ()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user