Sepearate Generation

This commit is contained in:
khodowany
2026-10-04 09:05:25 -05:00
parent e8ac7b5edd
commit 8fdd736e13
56 changed files with 1739 additions and 341 deletions
+9 -1
View File
@@ -41,6 +41,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--check", action="store_true")
parser.add_argument("--engraving-cache", type=pathlib.Path)
parser.add_argument("--jobs", type=int, default=1, choices=range(1, 17), metavar="1-16")
parser.add_argument("--composition-jobs", type=int, default=2, choices=range(1, 17), metavar="1-16")
parser.add_argument("--composition-cache", type=pathlib.Path)
parser.add_argument("--progress", action="store_true")
args = parser.parse_args()
if not args.renderer_fingerprint and (args.data_dir is None or args.output_dir is None):
@@ -55,6 +57,11 @@ def generate_assets(args: argparse.Namespace) -> None:
cache_dir = cache_dir.resolve() if cache_dir else None
if cache_dir and (cache_dir.is_relative_to(output_dir) or output_dir.is_relative_to(cache_dir)):
raise ValueError('engraving cache and generated assets must not overlap')
composition_cache = getattr(args,'composition_cache',None)
if composition_cache:
composition_cache=composition_cache.resolve()
if composition_cache.is_relative_to(output_dir) or output_dir.is_relative_to(composition_cache):
raise ValueError('composition cache and generated assets must not overlap')
progress = getattr(args, 'progress', False)
started = time.monotonic()
@@ -81,7 +88,8 @@ def generate_assets(args: argparse.Namespace) -> None:
staged.mkdir(parents=True, exist_ok=True)
copy_runtime_fonts(static_assets_dir, staged)
report('generating performance parts')
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences)
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms, scales=scales, sequences=sequences,
composition_jobs=getattr(args,'composition_jobs',2), cache_dir=getattr(args,'composition_cache',None), progress=progress)
generator.score.validate_generated_tree(staged)
if args.check:
generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
+3 -1
View File
@@ -139,7 +139,7 @@ def plan_section(measures: list[dict], library: generator.rhythm.Library, *, ini
bass = cycle(snippets, contexts, 'bass')
upper = cycle(snippets, contexts, 'upper')
companions = [tuple(sorted(set(sounding_attacks(bass[i % 4], item)) | set(sounding_attacks(upper[i % 4], item)))) for i, item in enumerate(contexts)]
response = cycle(snippets, contexts, 'electric', companions if acoustic_companions else None) if electric else upper
response = cycle(snippets, contexts, 'electric') if electric else upper
basses.extend(bass[i % 4] for i in range(len(segment)))
uppers.extend(upper[i % 4] for i in range(len(segment)))
responses.extend(response[i % 4] for i in range(len(segment)))
@@ -205,6 +205,8 @@ def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None, *,
unchanged = end - start == source.duration and sounding_end == end
duration = sounding_end - start
written = {'type': source.written_type, 'dots': source.dots} if unchanged else generator.model.written_for(duration)
if unchanged and source.ratio != 1:
written['ratio'] = generator.model.rational(source.ratio)
if not unchanged and (lane == 'electric' or not chord):
duration, written = next(written_durations(duration))
event = generator.model.make_event(f'{measure["id"]}_rhythm_{lane}_{len(events)}', start, duration, 'note' if chord else 'rest', written=written)
-20
View File
@@ -35,26 +35,6 @@ def voice(source, document, measures, part, initial=None, source_offset=0):
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']
+17 -35
View File
@@ -57,7 +57,7 @@ def plan_arrangement(document: dict, rhythms: generator.rhythm.Library) -> dict[
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:
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 = '', owned=None, accompaniment=None) -> dict:
if part_type not in generator.parts.PART_TYPES:
raise ValueError(f'unknown arrangement part: {part_type}')
staff = generator.score.source_staff(document)
@@ -72,30 +72,36 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
source_starts.append(source_time)
source_time += generator.model.fraction(m['duration'])
for index, section in enumerate(sections):
role = section.role if owned is None or section.role in owned else 'rest'
section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
previous = generator.accompaniment.carried_chords(section_measures)
segment = []
for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
m = section_measures[si]
rhythm = None
if section.role == 'accompaniment':
if role == 'accompaniment' and accompaniment is None:
plan = plans[section.section_index]
rhythm = (plan.acoustic_bass[len(segment)], plan.acoustic_upper[len(segment)]) if part_type == 'acoustic' else plan.electric[len(segment)]
result = generator.parts.build_measure(m, f'measure_{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm)
if role == 'accompaniment' and accompaniment is not None:
result = accompaniment(m, previous[si], si)
result['id'] = f'measure_{len(output) + len(segment) + 1}'
result['section_label'] = section.label if not segment else ''
else:
result = generator.parts.build_measure(m, f'measure_{len(output) + len(segment) + 1}', role, part_type, previous[si], section.label if not segment else '', rhythm)
segment.append(result)
if part_type == 'electric' and section.role in ('melody', 'accompaniment'):
voicing_origins.append(generator.electric.MeasureOrigin(source_starts[si], section.role, result))
segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction())
if section.role == 'slash':
solos.append(generator.scales.timeline.Solo(absolute, absolute + segment_length, section.label))
if section.role in ('vocal', 'melody'):
if role in ('vocal', 'melody'):
start = source_starts[section.source_measure_index]
for shift in staff.get('octave_shifts', []):
low, high = max(start, generator.model.fraction(shift['start'])), min(start + segment_length, generator.model.fraction(shift['end']))
if low < high:
octave_shifts.append({**shift, 'id': f'section_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(absolute + low - start), 'end': generator.model.rational(absolute + high - start)})
pickup = None
if section.role in ('vocal', 'melody') and generator.arrange.pickup.enters(section, source) and output:
if role in ('vocal', 'melody') and generator.arrange.pickup.enters(section, source) and output:
pickup = generator.parts.build_measure(source[0], f'pickup_{section.section_index}', section.role, part_type, None)
tuplets.extend(generator.score.reidentify(([pickup] if pickup else []) + segment, staff.get('tuplets', [])))
if pickup:
@@ -115,22 +121,20 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
tuplets = generator.arrange.pickup.retain_links(output, tuplets)
result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
result['metadata'].setdefault('tempo', 124)
if part_type == 'acoustic':
if part_type == 'acoustic' and accompaniment is None and (owned is None or 'accompaniment' in owned):
generator.acoustic.prepare(result)
generator.acoustic_voicing.place(result)
generator.acoustic.combine(result)
elif part_type == 'electric':
elif part_type == 'electric' and accompaniment is None and (owned is None or 'melody' in owned or 'accompaniment' in owned):
generator.melody.harmonize(result)
voicing_links = generator.electric.link_voicings(source, voicing_origins)
generator.guitar.place_guitar(result)
if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result)
generator.electric.reuse_voicings(voicing_links)
# Source labels identify the head while arranging; published labels identify
# 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':
if part_type != 'vocal' and owned is None:
generator.arrange.accompaniment.install(document, result, plans)
generator.fretboard.validate_document(result)
if scales and solos:
@@ -139,28 +143,6 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
reports.append(report)
return result
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library, scales: generator.scales.library.Library, sequences: generator.solo.sequences.Library, references=()) -> int:
count = 0
for path in generator.parts.source_paths(source_dir):
try:
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
plans = plan_arrangement(document, rhythms)
except ValueError as error:
raise ValueError(f'{path}: {error}') from error
parts = {}
for part in generator.parts.PART_TYPES:
try:
parts[part] = _arrange_document(document, part, plans)
except ValueError as error:
raise ValueError(f'{path}, part {part}: {error}') from error
try:
report = generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, references=references)
except ValueError as error:
raise ValueError(f'{path}: {error}') from error
for part, result in parts.items():
generator.score.write_json(output_dir / f'{generator.score.slugify(path.stem)}_{part}.json', result)
count += 1
report_path = output_dir.parent / 'analysis' / f'{generator.score.slugify(path.stem)}.txt'
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(document['metadata']['title'] + '\nSource: ' + path.name + '\n\n' + report, encoding='utf-8')
return count
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms=None, scales=None, sequences=None, composition_jobs=2, cache_dir=None, progress=False) -> int:
from generator.production import generate_tree
return generate_tree(source_dir, output_dir, jobs=composition_jobs, cache_dir=cache_dir, progress=progress)
+123
View File
@@ -0,0 +1,123 @@
"""Source-only sparse accompaniment, frozen once at each form position."""
from __future__ import annotations
import copy
import functools
import json
from fractions import Fraction as F
from generator import accompaniment, fretboard, guitar, harmony, model, parts, score, vocabulary
from generator.solo.policy import stable_rank
def semantic_key(chord):
return json.dumps({k: chord[k] for k in ('root', 'kind', 'degrees', 'bass', 'inversion') if k in chord}, sort_keys=True)
@functools.lru_cache(maxsize=2048)
def voicings(key, bass):
chord = json.loads(key)
degrees = harmony.degrees(chord)
root = int(model.NATURAL[chord['root']['letter']] + model.alteration(chord['root']['alter']))
classes = {d: (root + harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
required = {p for d, p in classes.items() if d in (2, 3, 4, 6, 7) or d == 5 and degrees[d] or d > 7}
full = fretboard.chord_requirements(chord)
required.update(full.basses)
if bass and 1 in classes:
required.add(classes[1])
if len(required) < 2:
required.update(classes[d] for d in (1, 5) if d in classes)
allowed = set(full.allowed)
requirements = fretboard.ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(allowed)), full.basses)
options = guitar.chord_positions(requirements, prune=False)
size = max(2, len(required))
options = [v for v in options if len(v) == size and fretboard.playable_attack((p, F(1)) for p in v)]
if not options:
raise ValueError(harmony.symbol(chord) + ': no legal sparse voicing preserving essential written degrees')
# Retain distinct hand regions and inversions, independent of enumeration.
best = {}
for v in options:
family = (min(p.fret for p in v), min(p.midi for p in v) % 12)
rank = (guitar.compactness(v), stable_rank('comp-voicing', v))
if family not in best or rank < best[family][0]:
best[family] = rank, v
return tuple(v for _, v in sorted(best.values()))
class Canonical:
def __init__(self, document, part, seed):
self.part, self.seed = part, seed
self.policy = vocabulary.role(part + '_accompaniment')
self.cache, self.previous, self.report = {}, (), []
measures = score.source_staff(document)['measures']
self.first_index = score.body_start_measure_index(measures)
self.last_index = len(measures)-1
carried = accompaniment.carried_chords(measures)
for index in range(score.body_start_measure_index(measures), len(measures)):
source = copy.deepcopy(measures[index])
for h in source['harmonies']:
h.pop('chord_optional', None)
self(source, carried[index], index)
def __call__(self, source, prior, index):
key = (index, tuple((model.fraction(h['onset']), semantic_key(h)) for h in source['harmonies']), semantic_key(prior) if prior else '', source['duration'].__repr__())
if key in self.cache:
return copy.deepcopy(self.cache[key])
length = model.fraction(source['duration'])
boundaries = sorted({F(), length, *(model.fraction(h['onset']) for h in source['harmonies'])})
attacks = []
source_attacks = {model.fraction(n['onset']) for n in score.measure_notes(source) if n['pitches'] and not all('tie_from' in p for p in n['pitches'])}
for start, end in zip(boundaries, boundaries[1:]):
chord = accompaniment.active_chord(source, start, prior)
if not chord:
continue
duration = end - start
# Essential bass is a source-derived form responsibility, independent
# of which other guitar happens to be playing or what it generated.
bass = self.policy.get('bass') == 'primary' or bool(chord.get('bass')) or index in (self.first_index,self.last_index) or start > 0
options = voicings(semantic_key(chord), bass)
center = self.policy.get('center', 55)
selected = min(options, key=lambda v: (
.08 * abs(sum(p.midi for p in v) / len(v) - center)
+ (.15 * guitar.movement(self.previous, v) if self.previous else 0)
+ .1 * guitar.compactness(v), stable_rank(self.seed, (index, start, v))))
self.previous = selected
patterns = []
for figure in vocabulary.data('rhythms')['comping']:
events = tuple((start+F(a), min(end, start+F(a)+F(d))) for a, d in figure['attacks'] if F(a) < duration)
# Space is available on held harmony, never an accidental empty winner.
if not events and (not prior or not accompaniment.same_chord(chord, prior) or start):
continue
conflicts = sum(a in source_attacks for a, _ in events)
target = 1 if duration <= 3 else 1.5
cost = .2*abs(len(events)-target) + .08*conflicts
if figure['id'] == 'sustain' and duration < 3:
cost += .2
patterns.append((cost, stable_rank(self.seed, (index, start, figure['id'])), events, figure['id']))
# Source-specific variation within a broad acceptability band, not a loop.
low = min(p[0] for p in patterns)
_, _, events, label = min((p for p in patterns if p[0] <= low+.25), key=lambda p:p[1])
for a, b in events:
attacks.append((a, b, selected, chord))
self.report.append({'source_index': index, 'onset': str(start), 'chord': harmony.symbol(chord), 'figure': label, 'tones': len(selected), 'bass': bass,
'fuller_voicing_reason': 'explicit written degrees/bass' if len(selected)>3 else ''})
notes, cursor = [], F()
def emit(a, b, positions=(), chord=None):
nonlocal cursor
previous = None
for duration, written in accompaniment.written_durations(b-a):
pitches = fretboard.chord_pitches(chord, positions) if positions else []
n = model.make_event(f'comp_{self.part}_{index}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
if previous:
for old, new in zip(previous['pitches'], n['pitches']):
old['tie_to'], new['tie_from'] = new['id'], old['id']
notes.append(n); previous = n; a += duration
cursor = b
for start, end, positions, chord in attacks:
if cursor < start: emit(cursor, start)
emit(start, end, positions, chord)
if cursor < length: emit(cursor, length)
result = parts.build_measure(source, 'canonical', 'rest', self.part, prior)
result['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
for note in notes: note['voice'] = 'voice_arranged'
self.cache[key] = copy.deepcopy(result)
return result
+24
View File
@@ -0,0 +1,24 @@
"""Explicit single-role build. Does not publish or consume an existing ensemble."""
import argparse
import json
import time
from pathlib import Path
from generator import musicxml, parts, production
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--song',type=Path,required=True)
parser.add_argument('--role',choices=production.ROLES,required=True)
parser.add_argument('--data-dir',type=Path,default=Path('data'))
parser.add_argument('--output',type=Path,required=True)
parser.add_argument('--cache-dir',type=Path)
parser.add_argument('--settings',type=Path)
args=parser.parse_args()
source=production.SourceAnalysis.read(parts.prepare_document(musicxml.load_musicxml(args.song)))
start=time.monotonic()
result=production.generate_role(source,args.role,args.data_dir,json.loads(args.settings.read_text()) if args.settings else {},args.cache_dir)
args.output.parent.mkdir(parents=True,exist_ok=True)
args.output.write_text(json.dumps(result,indent=2))
print(f'{args.role}: {time.monotonic()-start:.2f}s wall; cached={result["cache_hit"]}; original composition {result["seconds"]:.2f}s')
if __name__=='__main__':main()
+286
View File
@@ -0,0 +1,286 @@
"""Independent, reproducible role generation and non-composing assembly.
Only source documents and production resources enter generate_role. Completed
roles and published assets are downstream values, never composition inputs.
"""
from __future__ import annotations
import concurrent.futures
import copy
import dataclasses
import hashlib
import functools
import json
import multiprocessing
import os
from pathlib import Path
import tempfile
import time
from fractions import Fraction as F
from generator import accompaniment, comping, fretboard, model, musicxml, parts, rhythm, score, vocabulary
from generator.arrange import form, generate, pickup
from generator.solo import analysis, composer, notation, report, sequences
from generator.scales import library as scales
VERSION = 'independent-roles-1'
ROLES = ('acoustic_accompaniment', 'electric_accompaniment', 'electric_melody', 'vocal_melody', 'acoustic_solo', 'electric_solo')
@dataclasses.dataclass(frozen=True)
class SourceAnalysis:
document_json: str
musical_key: str
@classmethod
def read(cls, document):
prepared = copy.deepcopy(document)
staff = score.source_staff(prepared)
for i, m in enumerate(staff['measures']):
m['id'], m['number'] = f'source_{i}', str(i+1)
staff['tuplets'] = score.reidentify(staff['measures'], staff.get('tuplets', []))
prepared['metadata'] = {k:v for k,v in prepared['metadata'].items() if k not in ('title', 'composer', 'copyright', 'software')}
prepared['metadata'].update(title='Source', composer='')
staff.update(id='source', name='Source')
melodic = analysis.analyze(prepared)
content = {'melody': [(str(n.start), str(n.end), dataclasses.asdict(n.pitch)) for n in melodic.melody],
'measures': [{k: m.get(k) for k in ('duration', 'meter', 'key', 'harmonies', 'tempos', 'section_label', 'implicit')} for m in staff['measures']]}
# Fraction/string spelling is canonical and never includes filesystem IDs.
key = hashlib.sha256(json.dumps(content, sort_keys=True, default=str).encode()).hexdigest()
return cls(json.dumps(prepared, sort_keys=True), key)
def document(self):
return json.loads(self.document_json)
def windows(document, part):
source = score.source_staff(document)['measures']
sections = form.full_arrangement_sections(source, part)
result, cursor = [], F()
for section in sections:
duration = sum((model.fraction(m['duration']) for m in source[section.source_measure_index:section.source_measure_index+section.measure_count]), F())
role = {'slash': 'solo', 'vocal': 'melody'}.get(section.role, section.role)
if section.role in ('vocal', 'melody') and pickup.enters(section, source) and result:
attacks = [model.fraction(n['onset']) for n in score.measure_notes(source[0]) if n['pitches']]
if attacks:
start = cursor-model.fraction(source[0]['duration'])+min(attacks)
a, _, old = result[-1]; result[-1] = (a, start, old)
result.append((start, cursor, part+'_'+role))
end = cursor+duration
result.append((cursor, end, part+'_'+role))
if part == 'electric' and section.role == 'slash' and score.body_start_measure_index(source):
attacks = [model.fraction(n['onset']) for n in score.measure_notes(source[0]) if n['pitches']]
if attacks:
cutoff = end-model.fraction(source[0]['duration'])+min(attacks)
result[-1] = (cursor, cutoff, part+'_solo')
result.append((cutoff, end, part+'_rest'))
cursor = end
return tuple(result)
def dependencies(role, resources):
names = ('grammar', 'harmony', 'lead_ins', 'rhythms') if role.endswith('_solo') else ('rhythms',) if role.endswith('_accompaniment') else ()
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
**{name: resources[name] for name in names}}
def _code_key(role):
root = Path(__file__).parent
paths = [root / n for n in ('production.py', 'model.py', 'harmony.py', 'pitch.py', 'fretboard.py', 'parts.py', 'score.py', 'vocabulary.py')]
paths += list((root/'arrange').glob('*.py'))
if role.endswith('_solo'):
paths += [p for p in (root/'solo').glob('*.py') if 'audition' not in p.name and p.name != 'references.py']
paths += list((root/'scales').glob('*.py')) + [root/'rhythm.py']
elif role.endswith('_accompaniment'):
paths += [root/n for n in ('comping.py', 'accompaniment.py', 'guitar.py')]
elif role == 'electric_melody':
paths += [root/'guitar.py', root/'melody.py']
return vocabulary.fingerprint([(p.relative_to(root).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(set(paths))])
def _resource_key(directory, role):
names = ('scales', 'sequences', 'rhythms') if role.endswith('_solo') else ()
return {name: vocabulary.fingerprint([(p.relative_to(directory/name).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted((directory/name).rglob('*.xml'))]) for name in names}
@functools.lru_cache(maxsize=4)
def _solo_libraries(directory, fingerprints):
return scales.load_library(directory/'scales'),sequences.load_library(directory/'sequences'),rhythm.load_library(directory/'rhythms')
def _mask(document, allowed):
"""Apply source-defined role exclusions, without observing another part."""
absolute = F()
for measure in document['staves'][0]['measures']:
length = model.fraction(measure['duration'])
for voice in measure['voices']:
selected = []
for n in voice['notes']:
start = absolute+model.fraction(n['onset'])
end = start+model.fraction(n['duration'])
intervals = [(max(start,a),min(end,b)) for a,b in allowed if a < end and b > start]
for a,b in intervals:
note = copy.deepcopy(n)
if a != start:
note['onset'] = model.rational(a-absolute)
for pitch in note['pitches']: pitch.pop('tie_from',None)
pickup.truncate(note,b-absolute)
if a < b: selected.append(note)
voice['notes'] = _fill(selected, length, measure['id']+'_'+voice['id'], voice['id'])
absolute += length
staff=document['staves'][0]
staff['tuplets']=pickup.retain_links(staff['measures'],staff.get('tuplets',[]))
def _fill(notes, length, prefix, voice='voice_arranged'):
notes=sorted(notes,key=lambda n:model.fraction(n['onset']))
result=[]; cursor=F()
def rests(end):
nonlocal cursor
for duration,written in accompaniment.written_durations(end-cursor):
result.append(model.make_event(prefix+'_rest_'+str(len(result)),cursor,duration,'rest',written=written)|{'voice':voice})
cursor+=duration
for n in notes:
start=model.fraction(n['onset'])
if start<cursor: raise ValueError(prefix+': overlapping independently generated roles')
rests(start); n['voice']=voice;result.append(n)
cursor=start+model.fraction(n['duration'])
rests(length)
return result
def generate_role(source, role, data_dir, settings=None, cache_dir=None):
if role not in ROLES: raise ValueError('Unknown generation role: '+role)
data_dir=Path(data_dir); resources=vocabulary.load(data_dir/'composition')
settings=settings or {}
inputs={'version':VERSION,'source':source.musical_key,'role':role,'settings':settings,
'vocabulary':dependencies(role,resources),'catalogs':_resource_key(data_dir,role),'code':_code_key(role)}
# Source notation/lyrics also invalidate cached score representation, never the musical seed.
key=vocabulary.fingerprint({'inputs':inputs,'notation':source.document_json})
path=Path(cache_dir)/role/(key+'.json') if cache_dir else None
if path and path.is_file() and not path.is_symlink():
try:
entry=json.loads(path.read_text()); value=entry['result']
if entry['key']==key and entry['digest']==vocabulary.fingerprint(value):
model.validate_document(value['document']); fretboard.validate_document(value['document'])
return {**value,'cache_hit':True}
except (ValueError,KeyError,TypeError): pass
started=time.monotonic(); document=source.document(); part=role.split('_')[0]
trace={'source':source.musical_key,'role':role,'production_resources':inputs['catalogs'],
'vocabulary':vocabulary.fingerprint(inputs['vocabulary']),'settings':settings,'algorithm':VERSION,'code':inputs['code'],'cache_key':key}
seed=vocabulary.fingerprint({'source':source.musical_key,'role':role,'settings':settings,'data':inputs['vocabulary'],'version':VERSION})
details={}; solo_report=''
with vocabulary.using(resources,role,settings|{'choice_seed':seed}):
if role.endswith('_solo'):
result=generate._arrange_document(document,part,{},owned={'slash'})
from generator.solo.arrangement import regions,annotate
si=3 if part=='acoustic' else 4
first,count,offset,length=regions(document)[si]
# The template/harmony comes directly from source form, not another generated part.
authored=score.source_staff(document)['measures']
section=form.full_arrangement_sections(authored,part)[si]
effective=generate.section_source(authored,section,form.full_arrangement_sections(authored,part)[si+1])
template=copy.deepcopy(effective[section.source_measure_index:section.source_measure_index+count])
initial=accompaniment.carried_chords(effective)[section.source_measure_index]
end=next(b for a,b,r in windows(document,part) if r==role)-offset
scale_library,sequence_library,rhythm_library=_solo_libraries(data_dir,tuple(sorted(inputs['catalogs'].items())))
solo=composer.compose(analysis.analyze(document),template,part,end,
initial=initial,scales=scale_library,sequences=sequence_library,rhythms=rhythm_library,
enclosures_enabled=vocabulary.role().get('expressions',True))
output=notation.measures(result['staves'][0]['measures'][first:first+count],solo.notes(),'solo_'+part)
notation.replace_region(result,first,output);annotate(result,solo,offset)
details=report.structure(solo)
solo_report=report.describe(solo,scale_library,first,offset)
elif role.endswith('_accompaniment'):
canonical=comping.Canonical(document,part,seed)
result=generate._arrange_document(document,part,{},owned={'accompaniment'},accompaniment=canonical)
details={'canonical_comping':canonical.report}
else:
result=generate._arrange_document(document,part,{},owned={'vocal' if part=='vocal' else 'melody'})
allowed=tuple((a,b) for a,b,r in windows(document,part) if r==role)
_mask(result,allowed)
staff=result['staves'][0]
for measure in staff['measures']:
measure['id']=role+'_'+measure['id']
staff['tuplets']=score.reidentify(staff['measures'],staff.get('tuplets',[]))
if part!='vocal':result['staves'][0]['tablature']=True
model.validate_document(result);fretboard.validate_document(result)
value={'role':role,'document':result,'trace':trace,'details':details,'report':solo_report,'seconds':time.monotonic()-started,'cache_hit':False}
# Sidecar diagnostics use the same rational-safe representation as reports.
# Identical insertion order after cold generation and cache loading also
# keeps serialized scores and the catalog byte-for-byte reproducible.
value=json.loads(json.dumps(value,default=str,sort_keys=True))
if path:
path.parent.mkdir(parents=True,exist_ok=True)
payload={'key':key,'digest':vocabulary.fingerprint(value),'result':value}
with tempfile.NamedTemporaryFile('w',dir=path.parent,delete=False) as out:
json.dump(payload,out,sort_keys=True);temporary=out.name
os.replace(temporary,path)
return value
def assemble(source, results):
"""Union prevalidated role windows; no selection or pitch/fingering rewrite."""
documents={}
for part in parts.PART_TYPES:
selected=[results[r]['document'] for r in ROLES if r.startswith(part+'_')]
document=copy.deepcopy(selected[0]); staff=document['staves'][0]
if part=='vocal':
documents[part]=document;continue
staff['tuplets']=[];staff['octave_shifts']=[];staff['scale_spans']=[]
for other in selected:
for field in ('tuplets','octave_shifts','scale_spans'):
staff[field].extend(copy.deepcopy(other['staves'][0].get(field,[])))
for i,m in enumerate(staff['measures']):
# Tuplet rests belong to the source/role rhythm and must survive union.
sounding=[copy.deepcopy(n) for d in selected for n in score.measure_notes(d['staves'][0]['measures'][i]) if n['pitches'] or n.get('tuplet_ids')]
m['voices']=[{'id':'voice_arranged','notes':_fill(sounding,model.fraction(m['duration']),m['id'])}]
# Attribute changes belong to source melody pickups and are assembly metadata.
for d in selected:
other=d['staves'][0]['measures'][i]
if other.get('attributes'):m['attributes']=copy.deepcopy(other['attributes'])
staff['tuplets']=pickup.retain_links(staff['measures'],staff['tuplets'])
for span in staff['tuplets']:span['voice']='voice_arranged'
model.validate_document(document);fretboard.validate_document(document)
documents[part]=document
return documents
def _job(request):
source,role,directory,settings,cache=request
return generate_role(source,role,directory,settings,cache)
def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=False):
if type(jobs) is not int or not 1<=jobs<=16:
raise ValueError('composition workers must be between 1 and 16')
source_dir,output_dir=Path(source_dir),Path(output_dir)
sources={}
for path in parts.source_paths(source_dir):
document=parts.prepare_document(musicxml.load_musicxml(path))
sources[path]=(document,SourceAnalysis.read(document))
requests=[(path,role,(s,role,source_dir.parent,{},cache_dir)) for path,(_,s) in sources.items() for role in ROLES]
results={p:{} for p in sources}
def accept(path,role,value):
results[path][role]=value
if progress:print(f'Composition: {path.stem}/{role}: '+('cached' if value['cache_hit'] else f'{value["seconds"]:.1f}s'),flush=True)
if jobs==1:
for path,role,args in requests:
try:accept(path,role,_job(args))
except Exception as error:raise ValueError(f'{path.name}/{role}: {error}') from error
else:
with concurrent.futures.ProcessPoolExecutor(max_workers=jobs,mp_context=multiprocessing.get_context('spawn')) as pool:
futures={pool.submit(_job,args):(path,role) for path,role,args in requests}
for future in concurrent.futures.as_completed(futures):
path,role=futures[future]
try:accept(path,role,future.result())
except Exception as error:raise ValueError(f'{path.name}/{role}: {error}') from error
for path,(original,source) in sources.items():
assembled=assemble(source,results[path])
for part,document in assembled.items():
document['metadata']=copy.deepcopy(original['metadata'])
score.write_json(output_dir/(score.slugify(path.stem)+'_'+part+'.json'),document)
analysis_dir=output_dir.parent/'analysis';analysis_dir.mkdir(parents=True,exist_ok=True)
(analysis_dir/(score.slugify(path.stem)+'.txt')).write_text('\n'.join(results[path][r]['report'] for r in ROLES))
# Not a score file: diagnostics are deliberately outside the songs schema.
stable={role:{k:v for k,v in value.items() if k not in ('seconds','cache_hit')} for role,value in results[path].items()}
(analysis_dir/(score.slugify(path.stem)+'.roles')).write_text(json.dumps(stable,sort_keys=True,indent=2))
return len(sources)*3
+4 -5
View File
@@ -17,6 +17,7 @@ class Event:
dots: int = 0
beams: tuple[tuple[int, str], ...] = ()
continuation: bool = False
ratio: fractions.Fraction = fractions.Fraction(1)
@dataclasses.dataclass(frozen=True)
class Bar:
@@ -53,8 +54,8 @@ class Library:
def read_snippet(path: pathlib.Path, identifier: str) -> Snippet:
document = generator.musicxml.load_musicxml(path)
staff = document['staves'][0]
if len(staff['measures']) not in (1, 2) or staff.get('tuplets'):
raise ValueError(f'{path}: rhythm snippets require one or two bars without tuplets')
if len(staff['measures']) not in (1, 2):
raise ValueError(f'{path}: rhythm snippets require one or two bars')
meter = staff['measures'][0]['time_signature']
bars = []
for measure in staff['measures']:
@@ -69,9 +70,7 @@ def read_snippet(path: pathlib.Path, identifier: str) -> Snippet:
onset, duration = generator.model.fraction(note['onset']), generator.model.fraction(note['duration'])
if onset != cursor or duration <= 0 or note.get('grace') or note['kind'] not in ('note', 'rest') or note.get('tremolo'):
raise ValueError(f'{path}, measure {measure["number"]}: rhythm events must fill the bar sequentially')
if 'ratio' in note['written']:
raise ValueError(f'{path}, measure {measure["number"]}: rhythm snippets do not support time modifications')
events.append(Event(onset, duration, note['kind'] == 'note', note['written']['type'], note['written'].get('dots', 0), tuple((b['level'], b['type']) for b in note.get('beams', [])), any('tie_from' in p for p in note['pitches'])))
events.append(Event(onset, duration, note['kind'] == 'note', note['written']['type'], note['written'].get('dots', 0), tuple((b['level'], b['type']) for b in note.get('beams', [])), any('tie_from' in p for p in note['pitches']), generator.model.fraction(note['written'].get('ratio',generator.model.rational(1)))))
cursor += duration
if cursor != length:
raise ValueError(f'{path}: rhythm events do not fill the bar')
+8 -2
View File
@@ -40,6 +40,9 @@ def annotate(document, solo, offset):
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."""
if references:
raise ValueError('Audition references are not production composition inputs')
from generator.arrange import generate
analysis = generator.solo.analysis.analyze(source)
sections = regions(source)
source_measures = generator.score.source_staff(source)['measures']
@@ -47,8 +50,11 @@ def apply(source, parts, *, rhythms, scales, sequences, solos=None, revise=True,
requests = {}
for section_index, part in ((3, 'acoustic'), (4, 'electric')):
first, count, offset, length = sections[section_index]
initial = generator.accompaniment.carried_chords(parts[part]['staves'][0]['measures'])[first]
template = copy.deepcopy(parts[part]['staves'][0]['measures'][first:first + count])
form = generator.arrange.form.full_arrangement_sections(source_measures, part)
section = form[section_index]
effective = generate.section_source(source_measures, section, form[section_index+1])
initial = generator.accompaniment.carried_chords(effective)[section.source_measure_index]
template = copy.deepcopy(effective[section.source_measure_index:section.source_measure_index+count])
cutoff = length
if part == 'electric' and generator.score.body_start_measure_index(source_measures):
pickup = source_measures[0]
+2 -2
View File
@@ -83,7 +83,7 @@ def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=Tr
if destination.exists():
raise ValueError(f'{destination}: snapshot already exists; choose a new output root')
rhythms, scales, sequences = libraries or resources()
reference_library = generator.solo.references.load_library(pathlib.Path('data/lead_phrases'))
reference_library = generator.solo.references.load_library(pathlib.Path('audition/controls'))
result = {'enclosures_enabled':enclosures_enabled,'stage': stage, 'implementation': fingerprint(), 'reference_versions': [r.version for r in reference_library], 'songs': {}}
try:
result['policy'] = importlib.import_module('generator.solo.policy').describe()
@@ -107,7 +107,7 @@ def capture(root, stage, libraries=None, contexts=None, *, enclosures_enabled=Tr
if shared:
for context in shared.values():
context.diagnostics = generator.solo.realization.SearchContext().diagnostics
generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared, references=reference_library,enclosures_enabled=enclosures_enabled)
generator.solo.arrangement.apply(document, parts, rhythms=rhythms, scales=scales, sequences=sequences, solos=solos, revise=stage != 'phrase', contexts=shared,enclosures_enabled=enclosures_enabled)
song = {'source_sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'parts': parts, 'solos': {}, 'elapsed_seconds': time.monotonic() - started}
regions = generator.solo.arrangement.regions(document)
for solo, index in zip(solos, (3, 4)):
+11 -33
View File
@@ -20,6 +20,8 @@ import generator.solo.realization
import generator.solo.types
def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initial=None, revise=True, context=None, references=(), stage='elaborated', mode='thematic', enclosures_enabled=True):
if references or context is not None and getattr(context,'references',()):
raise ValueError('Audition references are not production composition inputs')
started = time.monotonic()
timeline = generator.scales.timeline.read({'measures': measures}, initial=initial)
region = generator.scales.timeline.Solo(fractions.Fraction(), end, part.capitalize() + ' Solo')
@@ -32,43 +34,18 @@ def compose(analysis, measures, part, end, *, scales, sequences, rhythms, initia
options = leads.solo_options(analysis, measures, part, end, choices, sequences, rhythms, context, stage, mode)
result = options[0][1]
result.diagnostics['strict_composition_seconds'] = time.monotonic()-started
result = generator.solo.enclosures.refine(result,sequences,enclosures_enabled and stage=='elaborated' and mode=='thematic')
from generator.solo import expressions
result = expressions.refine(result,enclosures_enabled and stage=='elaborated' and mode=='thematic')
validate(result)
return result
def compose_pair(analysis, requests, *, scales, sequences, rhythms, contexts=None, stage='elaborated', mode='thematic', enclosures_enabled=True):
"""Select two recognizable interpretations together, before arranging backing."""
from generator.solo import leads
started = time.monotonic()
pools = {}
for part, (measures,end,initial) in requests.items():
timeline = generator.scales.timeline.read({'measures': measures},initial=initial)
choices = generator.scales.selection.select(timeline,generator.scales.timeline.Solo(0,end,part),scales)
for c in choices:
if c.span.chord and c.scale is None:
raise ValueError(f'{part} solo, source measure {c.span.source_measure}: No exact match within the permitted catalog')
context = (contexts or {}).get(part) or generator.solo.realization.SearchContext()
pools[part] = leads.solo_options(analysis,measures,part,end,choices,sequences,rhythms,context,stage,mode)
def theme_key(solo):
home=next((p.state.home for p in solo.phrases if p.state and p.state.home),None)
if not home or not home.notes:return ()
return tuple((n.start-home.notes[0].start,n.end-n.start,n.pitch.number()-home.notes[0].pitch.number()) for n in home.notes)
pairs = [(a_cost+e_cost+2*leads.similarity(a,e),a,e) for a_cost,a in pools['acoustic'] for e_cost,e in pools['electric']
if mode!='thematic' or not theme_key(a) or theme_key(a)!=theme_key(e)]
if not pairs:
raise generator.solo.realization.SearchExhausted('Distinct acoustic/electric themes not found within the bounded pair search')
_,acoustic,electric = min(pairs,key=lambda v:(v[0],generator.solo.policy.stable_rank('solo-pair',(v[1].notes(),v[2].notes()))))
overlap = leads.similarity(acoustic,electric)
strict_seconds=time.monotonic()-started
selected=[]
for solo in (acoustic,electric):
solo.diagnostics.update(pair_similarity=overlap,pairs_compared=len(pairs),solo_alternatives=len(pools[solo.part]))
solo.diagnostics['strict_pair_seconds']=strict_seconds
solo=generator.solo.enclosures.refine(solo,sequences,enclosures_enabled and stage=='elaborated' and mode=='thematic')
validate(solo)
selected.append(solo)
return tuple(selected)
"""Compatibility batch helper: each winner is selected independently."""
return tuple(compose(analysis, measures, part, end, scales=scales, sequences=sequences,
rhythms=rhythms, initial=initial, context=(contexts or {}).get(part), stage=stage,
mode=mode, enclosures_enabled=enclosures_enabled)
for part, (measures, end, initial) in requests.items())
def phrase_notes(phrase):
@@ -144,7 +121,8 @@ def validate(solo):
harmony = generator.solo.harmonic.Harmony(solo.choices)
contracts = tuple(dict.fromkeys(t for phrase in solo.phrases if phrase.state for t in phrase.state.treatments))
enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures)
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures)
expressions = tuple(e for p in solo.phrases if p.draft for e in p.draft.expressions)
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures,expressions=expressions)
if not checked.valid:
raise ValueError(f'{solo.part} solo: {checked.error}')
for phrase in solo.phrases:
+9 -4
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import dataclasses
import fractions
import functools
import generator.model
import generator.solo.harmonic
@@ -14,7 +15,7 @@ import generator.solo.policy
import generator.solo.scoring
import generator.solo.sequences
import generator.solo.types
import generator.solo.references
import generator.solo.features
F = fractions.Fraction
@@ -47,9 +48,13 @@ class RhythmDraft:
octave: int = 0
def catalog(library, meter):
return _catalog(library.for_meter(meter))
@functools.lru_cache(maxsize=32)
def _catalog(snippets):
"""Read complete snippets; ties across authored bars stay one attack."""
result = {}
for snippet in library.for_meter(meter):
for snippet in snippets:
offset, spans = F(), []
for bar in snippet.bars:
for event in bar.events:
@@ -370,7 +375,7 @@ def contract_valid(draft, notes):
if 'opening_contour' in transform.preserves and directions != transform.directions[:len(directions)]:
return False, f'{transform.id}: identifying contour changed'
for feature in draft.features:
if generator.solo.references.feature_error(feature, local_sounding(notes)):
if generator.solo.features.feature_error(feature, local_sounding(notes)):
return False, f'{feature.id}: selected identifying relationship changed'
for first, repeated in draft.correspondence:
if first not in by_id or repeated not in by_id:
@@ -389,7 +394,7 @@ def relationship_features(draft, notes):
direction.extend(a != b for a, b in zip(actual, transform.directions))
reference = draft.reference_motif if transform.operation in ('statement', 'contrast') else draft.home
if reference and not draft.lead_identity and not draft.source_phrase and transform.operation not in ('close', 'fragment', 'broaden_ending'):
development.append(generator.solo.references.relationship(group, reference))
development.append(generator.solo.features.relationship(group, reference))
joins = []
for time in draft.joins:
left = next((n for n in reversed(notes) if n.start < time), None)
+223
View File
@@ -0,0 +1,223 @@
"""Typed, target-driven harmonic expression over a strictly mapped theme."""
from __future__ import annotations
import dataclasses
from fractions import Fraction as F
from generator import harmony as chord_model, model, pitch as spelling, vocabulary
from generator.solo import types, drafts, enclosures, placement, policy
def quality(chord):
degrees=chord_model.degrees(chord)
if degrees.get(3)==0 and degrees.get(7)==-1:return 'dominant'
if degrees.get(3)==-1:return 'half_diminished' if degrees.get(5)==-1 else 'minor'
if degrees.get(3)==0 and degrees.get(5)==0:return 'major'
return 'other'
def degree_pitch(chord,degree,alter,near):
root={**chord['root'],'octave':4}
p=spelling.shift_pitch(root,degree-1,chord_model.degree_semitones(degree,alter))
value=types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
return dataclasses.replace(value,octave=value.octave+round((near-value.number())/12))
def chromatic(target,delta):
# Chromatic neighbors are spelled relative to their exact destination.
if delta in (-2,-1,1,2):
step=1 if delta>0 else -1
root={'letter':target.letter,'alter':model.rational(target.alter),'octave':target.octave}
p=spelling.shift_pitch(root,step,delta)
return types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
raise ValueError('unsupported approach interval')
def eligible_color(rule,span,harmony):
chord=chord_model.parse_symbol(span.chord)
degrees=chord_model.degrees(chord)
if quality(chord) not in rule['qualities']:return False
degree=rule['degree']; simple=(degree-1)%7+1
# Explicit alterations and omissions take precedence over optional colors.
if any((d['value']-1)%7+1==simple and (d['type']=='subtract' or model.alteration(d['alter'])!=rule['alter']) for d in chord.get('degrees',[])):return False
if degree in degrees and degrees[degree]!=rule['alter']:return False
following=next((s for s in harmony.spans if s.start>=span.end and s.chord!=span.chord and s.scale),None)
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))%12
next_chord=chord_model.parse_symbol(following.chord) if following else None
next_root=int(model.NATURAL[next_chord['root']['letter']]+model.alteration(next_chord['root']['alter']))%12 if next_chord else None
function=rule.get('function')
if function=='resolving_dominant' and (next_root is None or (root-next_root)%12!=7):return False
if function=='tonic_or_predominant':
tonic=model.parse_pitch_name(span.scale.root)
tonic_pc=int(model.NATURAL[tonic['letter']]+model.alteration(tonic['alter']))%12
if root!=tonic_pc and not (next_chord and quality(next_chord)=='dominant' and (next_root-root)%12==5):return False
return True
def validate(notes,contracts,harmony):
"""Return event permissions only after checking complete actual gestures."""
actual={n.event_id:n for n in notes};approved={}
def same(expected):
n=actual.get(expected.event_id)
return n and (n.start,n.end,n.pitch)==(expected.start,expected.end,expected.pitch)
for c in contracts:
if not c.events or not all(same(n) for n in (*c.events,c.target)):
return {},c.id+': missing or changed expression event/target'
if c.preparation and not same(c.preparation):return {},c.id+': changed preparation'
if any(n.event_id in approved for n in c.events):return {},c.id+': overlapping expression permissions'
target=c.target; span=harmony.at(c.boundary if c.kind=='anticipation' else target.start)
if not span or not span.scale or span.chord!=c.chord:return {},c.id+': wrong target harmony'
if c.kind in ('approach','enclosure','connection'):
rule=next((r for r in vocabulary.data('lead_ins')['patterns'] if r['id']==c.rule and r['kind']==c.kind),None)
if not rule or len(c.events)!=len(rule['semitones']):return {},c.id+': unknown approach formula'
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in types.permitted(harmony.choices,target.start,target.end):return {},c.id+': invalid destination hold'
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())
if target.start-cursor>F(vocabulary.data('lead_ins')['max_approach']):return {},c.id+': approach too long'
for n,step,duration in zip(c.events,rule['semitones'],rule['durations']):
if n.start!=cursor or n.end!=cursor+F(duration) or n.pitch.number()!=target.pitch.number()+step:return {},c.id+': wrong register, interval, or timing'
cursor=n.end
if any(n.start<c.target.start and n.end>c.events[0].start and n.event_id not in {e.event_id for e in c.events} for n in notes):return {},c.id+': interrupted approach'
if c.kind=='connection' and (not c.preparation or c.preparation.end!=c.events[0].start or abs(c.preparation.pitch.number()-c.events[0].pitch.number())>2):return {},c.id+': chromatic connection lacks its incoming motion'
elif c.kind=='color':
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
if not rule:return {},c.id+': unknown color policy'
for n in c.events:
if n.end-n.start>F(rule['max_beats']):return {},c.id+': excessive color duration'
for region,a,b in harmony.segments(n):
if not region.scale or not eligible_color(rule,region,harmony):return {},c.id+': incompatible color harmony/function'
p=degree_pitch(chord_model.parse_symbol(region.chord),rule['degree'],rule['alter'],n.pitch.number())
if p!=n.pitch:return {},c.id+': color degree/spelling mismatch'
if not rule['outside'] and n.pitch.number()%12 not in region.scale.pitches:return {},c.id+': color requires mapped collection'
last=c.events[-1]
if rule.get('resolve'):
if target is last or not 0<=target.start-last.end<=F(vocabulary.data('harmony')['resolution_gap']) or not 0<abs(target.pitch.number()-last.pitch.number())<=2 or target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': unresolved color'
elif c.kind=='anticipation':
if c.rule!='early_chord_tone' or len(c.events)!=1 or c.target!=c.events[0] or span.start!=c.boundary:
return {},c.id+': unknown anticipation or non-boundary target'
n,=c.events
if c.boundary is None or not n.start<c.boundary<n.end or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval'
if n.pitch.number()%12 not in harmony.members(c.boundary,n.end) or n.pitch.number()%12 not in types.permitted(harmony.choices,c.boundary,n.end):return {},c.id+': invalid future destination'
elif c.kind=='blues':
if len(c.events)!=1:return {},c.id+': blues treatment requires one inflected event'
rule=next((r for r in vocabulary.data('lead_ins')['blues'] if r['id']==c.rule),None)
n,=c.events
chord=chord_model.parse_symbol(span.chord)
if not rule or n.end-n.start>F(1,2) or n.end!=target.start or target.pitch.number()%12 not in span.roles.basic:return {},c.id+': unresolved blues inflection'
expected=degree_pitch(chord,rule['degree'],rule['alter'],n.pitch.number())
resolved=degree_pitch(chord,rule['target_degree'],0,target.pitch.number())
if n.pitch!=expected or target.pitch!=resolved or target.pitch.number()-n.pitch.number()!=1:return {},c.id+': unsupported blues destination'
if not c.preparation or c.preparation.end!=n.start or abs(c.preparation.pitch.number()-n.pitch.number())>2:return {},c.id+': unprepared blues gesture'
else:return {},c.id+': unknown expression kind'
for n in c.events:
covered=n.start
for region,a,b in harmony.segments(n):
if a!=covered or not region.scale:return {},c.id+': missing harmony'
covered=b
if covered!=n.end:return {},c.id+': incomplete harmonic coverage'
approved[n.event_id]=c
return approved,''
def proposals(phrase,harmony):
draft=phrase.draft
if not draft or any(n.continuation for n in draft.notes):return
notes=types.sounding_notes(draft.notes)
for target,role in enclosures.declared_targets(phrase,harmony):
for rule in vocabulary.data('lead_ins')['patterns']:
start=target.start-F(rule['gap'])-sum((F(d) for d in rule['durations']),F())
if start<phrase.plan.start or not enclosures.editable(draft,start,target.start):continue
cursor=start;events=[]
for i,(step,duration) in enumerate(zip(rule['semitones'],rule['durations'])):
events.append(types.Note(cursor,cursor+F(duration),chromatic(target.pitch,step),target.sources,rule['kind'],event_id=target.event_id+':'+rule['id']+':'+str(i)))
cursor+=F(duration)
actual=enclosures.replace_window(notes,start,target.start,tuple(events))
if actual is None:continue
prior=next((n for n in reversed(actual) if n.end<=start),None)
contract=types.ExpressionContract(target.event_id+':'+rule['id'],rule['kind'],rule['id'],tuple(events),target,harmony.at(target.start).chord,preparation=prior)
yield actual,contract
for i,n in enumerate(notes[:-1]):
target=notes[i+1]
if not enclosures.editable(draft,n.start,n.end):continue
span=harmony.at(n.start)
if not span or not span.scale:continue
for rule in vocabulary.data('harmony')['colors']:
if not eligible_color(rule,span,harmony) or n.end-n.start>F(rule['max_beats']):continue
p=degree_pitch(chord_model.parse_symbol(span.chord),rule['degree'],rule['alter'],n.pitch.number())
if abs(p.number()-n.pitch.number())>4 or p==n.pitch:continue
color=dataclasses.replace(n,pitch=p,role='color')
resolution=target if rule.get('resolve') else color
contract=types.ExpressionContract(n.event_id+':'+rule['id'],'color',rule['id'],(color,),resolution,harmony.at(resolution.start).chord)
yield (*notes[:i],color,*notes[i+1:]),contract
for rule in vocabulary.data('lead_ins')['blues']:
if i==0 or n.end-n.start>F(1,2):continue
p=degree_pitch(chord_model.parse_symbol(span.chord),rule['degree'],rule['alter'],n.pitch.number())
changed=dataclasses.replace(n,pitch=p,role='blues')
yield (*notes[:i],changed,*notes[i+1:]),types.ExpressionContract(n.event_id+':'+rule['id'],'blues',rule['id'],(changed,),target,harmony.at(target.start).chord,preparation=notes[i-1])
for i,target in enumerate(notes):
if i==0 or target.start not in {s.start for s in harmony.spans}:continue
start=target.start-F(1,2)
if not enclosures.editable(draft,start,target.start):continue
n=dataclasses.replace(target,start=start,role='anticipation')
actual=enclosures.replace_window(notes,start,target.start,())
if actual:
actual=tuple(n if a.event_id==target.event_id else a for a in actual)
yield actual,types.ExpressionContract(target.event_id+':anticipate','anticipation','early_chord_tone',(n,),n,harmony.at(target.start).chord,boundary=target.start)
def refine(solo,enabled=True):
from generator.solo import harmonic
harmony=harmonic.Harmony(solo.choices,solo.profile);result=[];history=[];attempts=0
seed=vocabulary.role(solo.part+'_solo').get('choice_seed',solo.part)
for phrase in solo.phrases:
draft=dataclasses.replace(phrase.draft,notes=tuple(n for d in phrase.decisions for n in d.notes))
phrase=dataclasses.replace(phrase,draft=draft,before_enclosure=draft)
options=[(0.,phrase,None)]
if enabled:
original=draft.notes
for actual,c in proposals(phrase,harmony):
attempts+=1
if not drafts.contract_valid(draft,actual)[0]:continue
if not harmony.evaluate(actual,until=phrase.plan.end,complete=True,expressions=(c,)).valid:continue
if not harmonic.anchors_valid(phrase.anchors,actual,phrase.plan.end,complete=True,closure=phrase.plan.closure)[0]:continue
placed=placement.place(actual,solo.tempo,profile=solo.profile)
if not placed:continue
# Context, repetition, and motion determine utility; no chromatic count target.
prior=next((n for n in reversed(actual) if n.end<=c.events[0].start),None)
join=abs(prior.pitch.number()-c.events[0].pitch.number()) if prior else 0
duplicated=bool(prior and prior.pitch==c.events[0].pitch)
destination=c.target.pitch.number()
old_approach=next((n for n in reversed(original) if n.end<=c.target.start),None)
prepares=bool(old_approach and abs(old_approach.pitch.number()-destination)>2 and join<=3)
recent=sum(kind==c.kind for kind in history[-2:])
cost=.08*max(0,join-3)+.18*duplicated+.12*recent+.3*(enclosures.movement(actual)-enclosures.movement(original))-.12*prepares
if c.kind=='anticipation':cost-=.05
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
# Rebase editable development spans onto the completed gesture. The
# feature contract is unchanged, but stale event IDs/rhythm would make
# reports falsely describe notes that the expression replaced.
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,expressions=(c,),transformations=transforms)
routes=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))
options.append((cost,dataclasses.replace(phrase,draft=changed,decisions=decisions,routes=routes),c))
_,chosen,c=min(options,key=lambda v:(round(v[0],8),policy.stable_rank(seed,(phrase.plan.id,v[2]))))
if c:history.append(c.kind)
result.append(chosen)
raw=tuple(n for p in result for d in p.decisions for n in d.notes)
placed=placement.place(raw,solo.tempo,profile=solo.profile)
if raw and placed is None:raise ValueError('Expression realization has no complete guitar path')
by_id={n.event_id:n for n in placed[1]} if placed else {}
final=[]
for p in result:
decisions=tuple(dataclasses.replace(d,notes=tuple(by_id[n.event_id] for n in d.notes)) for d in p.decisions)
notes=tuple(n for d in decisions for n in d.notes)
state=dataclasses.replace(p.state,notes=notes,last=notes[-1] if notes else p.state.last) if p.state else None
# A legal timing revision preserves the complete route with its new times.
routes=[]
for route in p.routes:
if all(n.event_id in by_id and by_id[n.event_id].pitch==n.pitch for n in route.notes):
route_notes=tuple(by_id[n.event_id] for n in route.notes)
routes.append(dataclasses.replace(route,notes=route_notes,destination=route_notes[-1]))
final.append(dataclasses.replace(p,decisions=decisions,draft=dataclasses.replace(p.draft,notes=notes),state=state,routes=tuple(routes)))
return dataclasses.replace(solo,phrases=tuple(final),diagnostics={**solo.diagnostics,'expression_candidates':attempts,'expressions_selected':history,'enclosures_enabled':enabled})
+56
View File
@@ -0,0 +1,56 @@
"""Pure musical contracts; no reference files or filesystem access."""
from __future__ import annotations
import dataclasses
import fractions
import generator.solo.types as types
F = fractions.Fraction
def identifying_features(notes, source):
"""Select a characteristic leap followed by return, wherever it occurs."""
notes = tuple(notes)
candidates = []
for i in range(len(notes) - 2):
a, b, c = notes[i:i + 3]
leap, reply = b.pitch.number() - a.pitch.number(), c.pitch.number() - b.pitch.number()
if 3 <= abs(leap) <= 9 and leap * reply < 0 and c.start - a.start <= 4:
group = notes[i:min(len(notes), i + 4)]
rhythm = tuple((n.start - a.start, n.end - n.start) for n in group)
intervals = tuple(y.pitch.number() - x.pitch.number() for x, y in zip(group, group[1:]))
candidates.append(types.IdentifyingFeature(a.event_id + '_identity', tuple(n.event_id for n in group), rhythm, intervals, source))
return tuple(candidates[:1])
def feature_error(feature, notes):
by_id = {n.event_id: n for n in notes}
actual = [by_id.get(identity) for identity in feature.events]
if not actual or not all(actual):
return 1.0
first = actual[0].start
rhythm = tuple((n.start - first, n.end - n.start) for n in actual)
movement = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:]))
errors = []
if 'rhythm' in feature.preserves:
errors.append(1.0 if len(rhythm) != len(feature.rhythm) else sum(a != b for a,b in zip(rhythm,feature.rhythm))/max(1,len(rhythm)))
if 'intervals' in feature.preserves or 'contour' in feature.preserves:
if len(movement) != len(feature.intervals) or not movement and feature.intervals:
errors.append(1.0)
else:
errors.append(sum(((a>0)-(a<0)) != ((b>0)-(b<0)) or 'intervals' in feature.preserves and abs(a-b)>2 for a,b in zip(movement,feature.intervals))/max(1,len(movement)))
if 'exact_intervals' in feature.preserves:
errors.append(float(movement != feature.intervals))
if 'pitches' in feature.preserves:
errors.append(float(tuple(n.pitch.number() for n in actual) != feature.pitches))
return max(errors,default=0.0)
def relationship(notes, reference):
"""One joint relationship cost: timed movement, not absolute-pitch copying."""
notes = tuple(notes)
if len(notes) < 2 or len(reference.notes) < 2:
return 0.0 if len(notes) == len(reference.notes) == 1 else 1.0
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
expected = reference.contour()
count = min(len(actual), len(expected))
contour = sum(0.75 * (((a > 0) - (a < 0)) != ((b > 0) - (b < 0))) + 0.25 * min(1, max(0, abs(a - b) - 2) / 7) for a, b in zip(actual, expected)) / max(1, count)
start, ref_start = notes[0].start, reference.notes[0].start
rhythm = sum(min(1, float(abs((n.start - start) - (r.start - ref_start))) / 2) for n, r in zip(notes, reference.notes)) / min(len(notes), len(reference.notes))
evidence = max(0, min(3, len(reference.notes)) - len(notes)) / 3
return max(evidence, (contour + rhythm) / 2)
+15 -5
View File
@@ -148,8 +148,12 @@ class Harmony:
intervals.append((upper.start,target.start))
return accepted, ''
def evaluate(self, notes, treatments=(), *, until=None, complete=False, enclosures=()):
def evaluate(self, notes, treatments=(), *, until=None, complete=False, enclosures=(), expressions=()):
notes = generator.solo.types.sounding_notes(notes)
from generator.solo.expressions import validate
permissions, expression_error = validate(notes, expressions, self)
if expression_error:
return Result(False, 0, error=expression_error)
until = until if until is not None else max((n.end for n in notes), default=fractions.Fraction())
ornaments, error = self.enclosure_events(notes, enclosures, until) if enclosures else ({}, '')
if error:
@@ -190,13 +194,14 @@ class Harmony:
for span, start, end in self.segments(note):
contract = next((t for t in valid_contracts if t.pitch == number and t.start <= start and t.end >= end), None)
enclosure = ornaments.get(note.event_id)
exception = enclosure is not None and note.event_id == enclosure.lower.event_id
expression = permissions.get(note.event_id)
exception = enclosure is not None and note.event_id == enclosure.lower.event_id or expression is not None and (expression.kind != 'anticipation' or end <= expression.boundary)
if start > covered or not span.scale or number % 12 not in span.scale.pitches and not exception:
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)
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:
if function == 'basic' or note.role == 'quote' or brief_enclosure or expression:
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')
@@ -206,8 +211,12 @@ class Harmony:
if covered < note.end:
return Result(False, 0, error=f'{note.pitch.label()} at {covered}: missing harmonic coverage')
return Result(True, exposed / max(sounding, 0.001), tuple(pending))
def describe(self, notes, treatments=(), enclosures=()):
def describe(self, notes, treatments=(), enclosures=(), expressions=()):
notes = generator.solo.types.sounding_notes(notes)
from generator.solo.expressions import validate
permissions, error = validate(notes, expressions, self)
if error:
raise ValueError(error)
ornaments, error = self.enclosure_events(notes,enclosures) if enclosures else ({}, '')
if error:
raise ValueError(error)
@@ -215,7 +224,8 @@ class Harmony:
for index, note in enumerate(notes):
for span, start, end in self.segments(note):
contracts = [t for t in treatments if t.pitch == note.pitch.number() and t.start <= start and t.end >= end]
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'enclosure': ornaments[note.event_id].id if note.event_id in ornaments else '', 'scale_exception': note.event_id in ornaments and self.function(note.pitch.number(), span) == 'outside_collection', 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]})
expression = permissions.get(note.event_id)
result.append({'start': str(start), 'end': str(end), 'attack': str(note.start), 'sounding_duration': str(note.end - note.start), 'pitch': note.pitch.label(), 'chord': span.chord, 'function': self.function(note.pitch.number(), span), 'motion': self.connected_motion(notes, index), 'enclosure': ornaments[note.event_id].id if note.event_id in ornaments else '', 'expression': expression.kind if expression else '', 'expression_rule': expression.rule if expression else '', 'scale_exception': bool(note.event_id in ornaments or expression) and self.function(note.pitch.number(), span) == 'outside_collection', 'protected': note.role == 'quote', 'treatments': [t.id for t in contracts]})
return result
def anchor_note(anchor, notes, *, until=None):
+1 -1
View File
@@ -45,7 +45,7 @@ def document_for(template, notes, tempo, identifier):
def controls(song, libraries):
"""Literal reference injection is restricted to this diagnostic, never production."""
_, scales, sequences = libraries
reference, = references.load_library(pathlib.Path('data/lead_phrases'))
reference, = references.load_library(pathlib.Path('audition/controls'))
solo = song['solos']['acoustic']
measures = song['parts']['acoustic']['staves'][0]['measures'][solo['first']:solo['first'] + solo['count']]
choices = generator.scales.selection.select(generator.scales.timeline.read({'measures': measures}), generator.scales.timeline.Solo(F(), F(solo['length']), 'control'), scales)
+12 -9
View File
@@ -7,10 +7,12 @@ from __future__ import annotations
import dataclasses
import fractions
import functools
import generator.vocabulary
import generator.model
import generator.fretboard
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, references, profiles
from generator.solo import types, harmonic, policy, placement, routes, sequences, drafts, features as references, profiles
F = fractions.Fraction
@@ -133,10 +135,10 @@ def rhythm_catalog(library, meter):
return drafts.catalog(library,meter)
def connection_patterns(start,end,entry,destination,rhythms,meter):
length=end-start
@functools.lru_cache(maxsize=256)
def connection_windows(catalog,length):
spans={( (F(),length), ):'held'}
for identifier,pattern,total in rhythm_catalog(rhythms,meter):
for identifier,pattern,total in catalog:
if total < length:
continue
# Retrieve every exact-duration window, including offbeat and cross-bar windows.
@@ -151,6 +153,10 @@ def connection_patterns(start,end,entry,destination,rhythms,meter):
ranked=sequences.diverse(tuple(spans.items()),
lambda item:(abs(len(item[0])-float(length)*1.5),item[1]),
lambda item:(len(item[0]),any(a[1]!=b[0] for a,b in zip(item[0],item[0][1:]))),8)
return ranked
def connection_patterns(start,end,entry,destination,rhythms,meter):
ranked=connection_windows(tuple(rhythm_catalog(rhythms,meter)),end-start)
result=[]
for pattern,identifier in ranked:
events=tuple(dataclasses.replace(entry,start=start+a,end=start+b,role='connection',
@@ -323,9 +329,10 @@ def transition(first,second):
def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,stage):
from generator.solo import realization,themes,phrasing
context.resources[id(library)]=library
context.tempo=analysis.tempo
context.resources[id(rhythms)]=rhythms
harmony=harmonic.Harmony(choices, profiles.for_part(part,analysis))
seed=policy.stable_rank('source_variations',(analysis.melody,choices,policy.SEED_NAMESPACE))
seed=policy.stable_rank('source_variations',(part,analysis.melody,choices,generator.vocabulary.role(part+'_solo'),policy.DEFAULT.composition_version))
states=[(0.,())]
failures=[]
for _ in range(2*len(measures)+1):
@@ -372,10 +379,6 @@ def thematic_options(analysis,measures,part,end,choices,library,rhythms,context,
def rank(value):
cost,history=value;state=history[-1].state;elapsed=history[-1].plan.end
musical=cost/max(.001,float(elapsed/end))
if elapsed==end:
target=policy.DEFAULT.settling_target*float(end)/(32*4)
musical+=.1*max(0,abs(state.closures-target)-1)
musical+=.15*abs(len(state.returns)-policy.DEFAULT.return_target*float(end)/(32*4))
return musical,policy.stable_rank(seed,tuple(p.draft.id for p in history))
states=sequences.diverse(following,rank,
lambda v:(v[1][-1].state.home.id if v[1][-1].state.home else '',v[1][-1].plan.end,len(v[1][-1].state.returns)),policy.DEFAULT.chorus_frontier)
+20 -12
View File
@@ -60,20 +60,28 @@ def measures(sources: list[dict], notes: tuple[generator.solo.types.Note, ...],
return result
def tuplets(measures):
spans = []
for measure in measures:
for ratio, values in itertools.groupby(generator.score.measure_notes(measure), key=lambda n: generator.model.fraction(n['written'].get('ratio', generator.model.rational(1)))):
notes = list(values)
if ratio == 1:
continue
for first in range(0, len(notes), ratio.denominator):
members = notes[first:first + ratio.denominator]
identifier = members[0]['id'] + '_tuplet'
spans.append({'id': identifier, 'voice': 'voice_arranged', 'start': members[0]['id'], 'end': members[-1]['id'], 'note_count': ratio.denominator, 'space_count': ratio.numerator, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'})
for note in members:
note['tuplet_ids'] = [identifier]
# Group by sounding duration, including notation fragments across barlines.
spans=[]
events=[n for m in measures for n in generator.score.measure_notes(m)]
for ratio,values in itertools.groupby(events,key=lambda n:generator.model.fraction(n['written'].get('ratio',generator.model.rational(1)))):
if ratio==1:continue
members=[];elapsed=fractions.Fraction();length=None
for note in values:
if not members:
length=generator.model.TYPE_LENGTHS[note['written']['type']]*ratio.numerator
members.append(note);elapsed+=generator.model.fraction(note['duration'])
if elapsed>=length:
identifier=members[0]['id']+'_tuplet'
spans.append({'id':identifier,'voice':'voice_arranged','start':members[0]['id'],'end':members[-1]['id'],'note_count':ratio.denominator,'space_count':ratio.numerator,'depth':0,'bracket':'auto','show_number':'actual'})
for n in members:n['tuplet_ids']=[identifier]
members=[];elapsed=fractions.Fraction()
if members:
identifier=members[0]['id']+'_tuplet'
spans.append({'id':identifier,'voice':'voice_arranged','start':members[0]['id'],'end':members[-1]['id'],'note_count':ratio.denominator,'space_count':ratio.numerator,'depth':0,'bracket':'auto','show_number':'actual'})
for n in members:n['tuplet_ids']=[identifier]
return spans
def replace_region(document: dict, first: int, replacement: list[dict]) -> None:
staff = document['staves'][0]
replacement = copy.deepcopy(replacement)
+18 -17
View File
@@ -7,14 +7,15 @@ from fractions import Fraction as F
import generator.model
import generator.harmony
import generator.vocabulary
from generator.solo import types, policy, sequences, profiles
def heard(notes, operation, anchor=None):
def heard(notes, operation, anchor=None, bar=F(4)):
notes = types.sounding_notes(notes)
first = notes[0].start
events = tuple((n.start-first, n.end-n.start, n.pitch.number()) for n in notes)
ending = (anchor.pitch.number()%12, anchor.earliest%4, anchor.duration) if anchor and anchor.role == 'terminal' else ()
ending = (anchor.pitch.number()%12, anchor.earliest%bar, anchor.duration) if anchor and anchor.role == 'terminal' else ()
return types.HeardFigure(first, notes[-1].end, events, operation, ending)
@@ -56,7 +57,10 @@ 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))
trajectory=generator.vocabulary.role(part+'_solo').get('register_motion',[0,4,-3,0])
phase=min(len(trajectory)-1,incoming.closures%len(trajectory))
goal=profile.reference+trajectory[phase]
return max(profile.register[0]+3,min(profile.register[1]-3,round((center+goal)/2)))
recent = [p for h in incoming.history[-2:] for _,_,p in h.events]
if not recent:
return fallback
@@ -72,9 +76,7 @@ def register_goal(incoming, part, fallback, profile=None):
def endpoint_cost(finish, start, end, count, harmony, bar):
length = float((finish-start)/bar)
remaining = float((end-finish)/bar)
projected = count+1+remaining/(32/policy.DEFAULT.settling_target)
low, high = policy.DEFAULT.settling_range
pacing = .2*max(0,low-projected,projected-high)+.025*abs(projected-policy.DEFAULT.settling_target)
pacing = 0.
span = harmony.at(finish-F(1,100))
# A dominant may close an open question, but a nearby resolution is preferred.
degrees=generator.harmony.degrees(generator.harmony.parse_symbol(span.chord)) if span and span.scale else {}
@@ -94,7 +96,8 @@ def window_options(analysis, measures, start, end, harmony, incoming, *, limit=2
ends=(min(end,current.end if current else bounds[index+1]),)
else:
barrier=min((s.start for s in harmony.spans if not s.scale and s.start>start),default=end)
ends=tuple(sorted({min(end,barrier,bounds[min(index+n,len(measures))]) for n in (4,5,6,8)}))
authored=[n.end-analysis.body_offset for n in analysis.melody if n.end-n.start>=2]
ends=tuple(sorted({min(end,barrier,bounds[min(index+n,len(measures))]) for n in (3,4,5,6,8)} | {t for t in authored if start+3*bar<=t<=min(end,barrier,start+8*bar)}))
if barrier>=end:
# The returning pickup may shorten the last measure. Do not strand a
# one-beat "new phrase" after an otherwise completed sustained close.
@@ -116,9 +119,9 @@ def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
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),)
holds=tuple(F(v) for v in generator.vocabulary.data('grammar')['ending_holds']) if duration>=4 else (min(F(2),duration/2),)
for hold in holds:
for release in (F(),F(1,2),F(1),F(2)):
for release in map(F,generator.vocabulary.data('grammar')['ending_releases']):
arrival=source.end-release-hold
if arrival-source.start<F(3,2):continue
for pitch in available(harmony,arrival,arrival+hold,members=True):
@@ -126,8 +129,8 @@ def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
anchor=types.Anchor(f'{source.id}:close:{arrival}:{pitch.number()}',arrival,arrival,pitch,
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=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)
cadence=sum(h.ending==(pitch.number()%12,arrival%bar,hold) for h in incoming.history)
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)
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)
@@ -135,12 +138,10 @@ def endings(source, incoming, part, harmony, bar, *, limit=8, final=False):
def appearance_cost(operation, incoming, start, end):
expected=policy.DEFAULT.return_target*float(start/max(end,F(1)))
actual=len(incoming.returns)
gap=start-(incoming.returns[-1] if incoming.returns else F())
if operation=='return':
gap=start-(incoming.returns[-1] if incoming.returns else F())
return .65*max(0,actual+1-expected)+.5*max(0,1-float(gap)/24)
return .45*max(0,expected-actual)
return .5*max(0,1-float(gap)/16)
return .1*min(1,max(0,float(gap)/32-1))
def activity(source, ending):
@@ -162,7 +163,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 harmony.profile.cost(profiles.weighted_center(notes))+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+.8*history_cost
@functools.lru_cache(maxsize=512)
+1 -3
View File
@@ -45,14 +45,12 @@ class Policy:
theme_continuations: int = 8
theme_route_jobs: int = 16
settling_range: tuple[int, int] = (5, 7)
settling_target: int = 6
return_target: int = 2
history_bars: int = 16
enclosure_targets: int = 4
enclosure_routes: int = 4
enclosure_frontier: int = 8
enclosure_max_per_32_bars: int = 4
composition_version: str = 'six-parts-register-signatures-1'
composition_version: str = 'independent-roles-1'
# Preserve the former default root seeds while decoupling them from policy edits.
SEED_NAMESPACE = 'a85abb240d7cfa9d054375b654bcb5c23c746201fd4aed7acf81cb3f4ee720e1'
+4 -3
View File
@@ -5,6 +5,7 @@ import dataclasses
from fractions import Fraction as F
import generator.fretboard
import generator.vocabulary
@dataclasses.dataclass(frozen=True)
@@ -23,7 +24,7 @@ class Instrument:
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.
return .5 * min(1., max(0, abs(number - self.reference) - 7) / 12) if self.reference is not None else 0.
UNRESTRICTED = Instrument()
@@ -43,13 +44,13 @@ def weighted_center(notes):
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)
strings = (1, 2, 3, 4, 5, 6)
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
shift = generator.vocabulary.role(part + '_solo').get('register_offset', 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))
+2 -2
View File
@@ -6,7 +6,7 @@ import functools
import time
import generator.solo.drafts
import generator.solo.references
import generator.solo.features
import generator.solo.routes
import generator.fretboard
@@ -700,7 +700,7 @@ def refine(phrase, pool, incoming, harmony, sequences, tempo, context, search=Tr
notes = emitted_notes(phrase)
draft = rebase_draft(phrase.draft, notes, harmony)
# Only now commit a selected, inspectable relationship for elaboration.
features = generator.solo.references.identifying_features(notes, draft.reference)
features = generator.solo.features.identifying_features(notes, draft.reference)
draft = dataclasses.replace(draft, features=features)
# Rebuild lineage after group removal or replacement, including when no run
# is selected afterward. The introduced home is the actual refined tune.
+1 -49
View File
@@ -76,52 +76,4 @@ def read(path):
def load_library(root: pathlib.Path):
return tuple(read(path) for path in sorted(root.glob('*.json')))
def identifying_features(notes, source):
"""Select a characteristic leap followed by return, wherever it occurs."""
notes = tuple(notes)
candidates = []
for i in range(len(notes) - 2):
a, b, c = notes[i:i + 3]
leap, reply = b.pitch.number() - a.pitch.number(), c.pitch.number() - b.pitch.number()
if 3 <= abs(leap) <= 9 and leap * reply < 0 and c.start - a.start <= 4:
group = notes[i:min(len(notes), i + 4)]
rhythm = tuple((n.start - a.start, n.end - n.start) for n in group)
intervals = tuple(y.pitch.number() - x.pitch.number() for x, y in zip(group, group[1:]))
candidates.append(types.IdentifyingFeature(a.event_id + '_identity', tuple(n.event_id for n in group), rhythm, intervals, source))
return tuple(candidates[:1])
def feature_error(feature, notes):
by_id = {n.event_id: n for n in notes}
actual = [by_id.get(identity) for identity in feature.events]
if not actual or not all(actual):
return 1.0
first = actual[0].start
rhythm = tuple((n.start - first, n.end - n.start) for n in actual)
movement = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(actual, actual[1:]))
errors = []
if 'rhythm' in feature.preserves:
errors.append(1.0 if len(rhythm) != len(feature.rhythm) else sum(a != b for a,b in zip(rhythm,feature.rhythm))/max(1,len(rhythm)))
if 'intervals' in feature.preserves or 'contour' in feature.preserves:
if len(movement) != len(feature.intervals) or not movement and feature.intervals:
errors.append(1.0)
else:
errors.append(sum(((a>0)-(a<0)) != ((b>0)-(b<0)) or 'intervals' in feature.preserves and abs(a-b)>2 for a,b in zip(movement,feature.intervals))/max(1,len(movement)))
if 'exact_intervals' in feature.preserves:
errors.append(float(movement != feature.intervals))
if 'pitches' in feature.preserves:
errors.append(float(tuple(n.pitch.number() for n in actual) != feature.pitches))
return max(errors,default=0.0)
def relationship(notes, reference):
"""One joint relationship cost: timed movement, not absolute-pitch copying."""
notes = tuple(notes)
if len(notes) < 2 or len(reference.notes) < 2:
return 0.0 if len(notes) == len(reference.notes) == 1 else 1.0
actual = tuple(b.pitch.number() - a.pitch.number() for a, b in zip(notes, notes[1:]))
expected = reference.contour()
count = min(len(actual), len(expected))
contour = sum(0.75 * (((a > 0) - (a < 0)) != ((b > 0) - (b < 0))) + 0.25 * min(1, max(0, abs(a - b) - 2) / 7) for a, b in zip(actual, expected)) / max(1, count)
start, ref_start = notes[0].start, reference.notes[0].start
rhythm = sum(min(1, float(abs((n.start - start) - (r.start - ref_start))) / 2) for n, r in zip(notes, reference.notes)) / min(len(notes), len(reference.notes))
evidence = max(0, min(3, len(reference.notes)) - len(notes)) / 3
return max(evidence, (contour + rhythm) / 2)
from generator.solo.features import identifying_features, feature_error, relationship
+9 -6
View File
@@ -13,7 +13,7 @@ import generator.solo.types
import generator.solo.harmonic
import generator.solo.drafts
import generator.solo.similarity
import generator.solo.references
import generator.solo.features
import generator.solo.placement
def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_offset=0) -> str:
@@ -32,7 +32,7 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
lines.extend(('', f'{plan.id}: {plan.start}-{plan.end}; {plan.relationship}; motif {plan.motif.id}', f' Origin: {plan.motif.origin}; source events: ' + ', '.join(s for n in plan.motif.notes for s in n.sources)))
lines.append(f' Contract: {plan.treatment}; closure: {plan.closure}; ending: {plan.ending}; score: {phrase.score:.4f}; identity: {plan.seed}')
if plan.treatment in ('source_variation', 'thematic_recomposition'):
lines.append(' Activity preference: 4–6 attacks per four beats; source phrasing takes precedence. Occupancy is diagnostic, not a target.')
lines.append(' Activity follows the selected gesture and tempo-aware vocabulary; occupancy is diagnostic, not a target.')
if phrase.draft:
draft = phrase.draft
lines.append(f' Complete draft {draft.id}: {draft.relationship}; home {draft.home.id if draft.home else "none"}; {len(draft.notes)} attacks; rests {draft.rests()}')
@@ -76,8 +76,10 @@ def describe(solo: generator.solo.types.Solo, scales, measure_offset=0, time_off
degrees = [str(d) for d, a in generator.harmony.degrees(choice.span.chord).items() if (root + generator.harmony.degree_semitones(d, a)) % 12 == note.pitch.number() % 12]
function = 'chord degree ' + '/'.join(degrees) if degrees else 'in-collection connection'
if note.pitch.number()%12 not in generator.solo.types.permitted(solo.choices,note.start,note.end):
function='validated enclosure exception'
function='validated expression exception'
lines.append(f' {note.start}-{note.end}: {note.pitch.label()}; {note.role}; {generator.harmony.symbol(choice.span.chord)}, {function}; {choice.scale.label()}; string {note.position.string_number}, fret {note.position.fret}')
for expression in detail['expressions']:
lines.append('Expression: ' + expression['kind'] + ' / ' + expression['rule'] + ' -> ' + str(expression['target']['pitch']))
lines.append('\nSounding harmonic spans (ties merged before classification):')
for event in detail['harmonic_roles']:
if float(fractions.Fraction(event['sounding_duration'])) >= 1 or event['protected'] or event['enclosure']:
@@ -88,7 +90,8 @@ def structure(solo):
harmony = generator.solo.harmonic.Harmony(solo.choices)
contracts = tuple(dict.fromkeys(t for p in solo.phrases if p.state for t in p.state.treatments))
enclosures = tuple(e for p in solo.phrases if p.draft for e in p.draft.enclosures)
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures)
expressions = tuple(e for p in solo.phrases if p.draft for e in p.draft.expressions)
checked = harmony.evaluate(solo.notes(), contracts, until=solo.end, complete=True,enclosures=enclosures,expressions=expressions)
def rational(value):
if isinstance(value, dict):
@@ -122,10 +125,10 @@ def structure(solo):
entry['head_comparison_plain']=generator.solo.similarity.compare(phrase.refined.notes,local_source)
entry['head_comparison_elaborated']=generator.solo.similarity.compare(tuple(n for d in phrase.decisions for n in d.notes),local_source)
if phrase.state and phrase.state.home:
entry['theme_relationship']=generator.solo.references.relationship(phrase.refined.notes,phrase.state.home)
entry['theme_relationship']=generator.solo.features.relationship(phrase.refined.notes,phrase.state.home)
entry['score'] = phrase.score
phrases.append(entry)
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])}
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, 'expressions': rational([dataclasses.asdict(e) for e in expressions]), 'enclosures': rational([dataclasses.asdict(e) for e in enclosures]), 'harmonic_roles': harmony.describe(solo.notes(), contracts,enclosures,expressions), '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):
+33 -12
View File
@@ -10,7 +10,8 @@ from fractions import Fraction as F
import generator.model
import generator.harmony
from generator.solo import types, policy, sequences, routes, references, similarity, phrasing, profiles
import generator.vocabulary
from generator.solo import types, policy, sequences, routes, features as references, similarity, phrasing, profiles
def source_notes(analysis):
@@ -60,20 +61,23 @@ def seeds(analysis, bar, seed):
event_id=n.sources[0]) for n in analysis.melody)
groups=[]
for i in range(len(melody)):
for count in ((len(melody),) if len(melody)<3 else (3,4,5)):
for count in ((len(melody),) if len(melody)<3 else (3,4,5,6,7,8)):
group=melody[i:i+count]
if len(group)!=count or group[-1].end-group[0].start>max(bar,F(3)) or group[0].start>=8*bar:
if len(group)!=count or group[-1].end-group[0].start>max(2*bar,F(3)):
continue
local=tuple(dataclasses.replace(n,start=n.start-group[0].start,end=n.end-group[0].start) for n in group)
parent=next((s.id for s in analysis.source_phrases if s.start<=max(0,group[0].start)<s.end),'pickup')
for treatment in ('rhythmic_seed','contour_seed','opening_fragment','characteristic_interval'):
for treatment in generator.vocabulary.role().get('preferred_features',('rhythmic_seed','contour_seed','opening_fragment','characteristic_interval')):
identity=policy.stable_rank(seed,(group,treatment))[:12]
groups.append(types.SourceReference(identity,parent,group[0].start,group[-1].end,local,treatment))
def rank(ref):
rhythm={(n.start,n.end-n.start) for n in ref.notes}
intervals={b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])}
return (-len(intervals),-len(rhythm),ref.start,ref.id)
return sequences.diverse(groups,rank,lambda r:r.treatment,policy.DEFAULT.theme_seed_limit)
preferred=generator.vocabulary.role().get('preferred_features',[])
preference=preferred.index(ref.treatment) if ref.treatment in preferred else 0
turn=any(a*b<0 for a,b in zip(ref.notes and tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:])),tuple(b.pitch.number()-a.pitch.number() for a,b in zip(ref.notes,ref.notes[1:]))[1:]))
return (-min(4,len(intervals))-.3*turn+.15*preference,-min(3,len(rhythm)),ref.id)
return sequences.diverse(groups,rank,lambda r:(r.treatment,int(max(0,r.start)//(4*bar))),policy.DEFAULT.theme_seed_limit)
def feature(reference, actual, *, treatment=None):
@@ -85,6 +89,8 @@ def feature(reference, actual, *, treatment=None):
'return':('rhythm','intervals'), 'develop_rhythm':('rhythm',),
'develop_contour':('intervals',), 'continue':('contour',)}[treatment]
original=reference.notes
authored=next((r for r in generator.vocabulary.data('grammar')['transformations'] if r['id']==treatment),None)
if authored:required=tuple(authored['preserve'])
origin=original[0].start
octave=12*round((actual[0].pitch.number()-original[0].pitch.number())/12)
return types.IdentifyingFeature(reference.id+':'+treatment,tuple(n.event_id for n in actual),
@@ -95,7 +101,8 @@ def feature(reference, actual, *, treatment=None):
def rhythm_windows(library,meter,length,context):
from generator.solo import drafts
key=('theme-rhythm',id(library),meter,length)
tempo=getattr(context,'tempo',120)
key=('theme-rhythm',id(library),meter,length,tempo)
if key not in context.pools:
patterns={}
bar=F(meter.split('/')[0])*4/int(meter.split('/')[1])
@@ -115,9 +122,18 @@ def rhythm_windows(library,meter,length,context):
if offset+length>total or any(a<offset<b or a<offset+length<b for a,b in pattern):continue
selected=tuple((a-offset,b-offset) for a,b in pattern if offset<=a and b<=offset+length)
if selected and len(selected)<=16:patterns.setdefault(selected,identifier)
# Authored cells can start late and continue over computational barlines.
for identifier,pattern,total in generator.vocabulary.rhythm_cells(tempo):
if total>length:continue
for offset in (F(),F(1,2),max(F(),length-total)):
if offset+total>length:continue
head=tuple((a+offset,b+offset) for a,b in pattern)
tails=tuple((a,b) for a,b in next(iter(patterns),()) if a>=offset+total)
combined=(*head,*tails)
if 2<=len(combined)<=16:patterns.setdefault(combined,'authored:'+identifier)
context.pools[key]=sequences.diverse(tuple(patterns.items()),
lambda x:(not .7*float(length)<=len(x[0])<=1.8*float(length),policy.stable_rank('rhythm-window',x[0])),
lambda x:(len(x[0]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8)
lambda x:(len(x[0])>16,policy.stable_rank('rhythm-window',x[0])),
lambda x:(x[1].startswith('authored:'),len(x[0]),x[0][0][0],any(a[1]!=b[0] for a,b in zip(x[0],x[0][1:]))),8)
return context.pools[key]
@@ -366,6 +382,11 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
cursor=source.start
while cursor<source.end and states:
finish=min(cursor+unit_length,source.end)
if cursor==source.start and source.end-source.start>=3*bar:
# Selected home rhythms finish as objects, not at the nearest barline.
signature=incoming.home.signature if incoming.home else ()
if signature and signature[-1].end>unit_length:
finish=min(source.end,cursor+signature[-1].end)
if source.end-finish<6 or any(ending.earliest<finish for *_,ending in states):finish=source.end
next_states=[]
for cost,units,home,state,ending in states:
@@ -409,7 +430,7 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
if introducing and similarity.near_copy(unit.notes,head,cursor,finish)['near_copy']:
context.diagnostics['near_copy_rejections']=context.diagnostics.get('near_copy_rejections',0)+1;continue
actual_home=home or make_home(unit,harmony,cursor,finish)
figure=phrasing.heard(unit.notes,operation,unit.anchor)
figure=phrasing.heard(unit.notes,operation,unit.anchor,bar)
repetition=phrasing.repetition(figure,state.history,bar)
appearance=phrasing.appearance_cost(operation,state,cursor,solo_end) if home else 0.
history=phrasing.remember(state.history,figure,bar)
@@ -423,7 +444,7 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
remaining=max(F(),ending.earliest-finish)
possible=attacks+2*float(remaining)+(0 if finish==source.end else 1)
expected=float(source.end-source.start)
activity_bound=.35*max(0,expected-possible,attacks-1.5*expected)/max(1,float(source.end-source.start)/4)
activity_bound=0.0 # Activity is a gesture/difficulty property, not a chorus quota.
return cost/max(1,len(units))+activity_bound,policy.stable_rank(seed,tuple(u.notes for u in units))
states=sequences.diverse(next_states,rank,
lambda v:(v[2].id,v[4].pitch.number(),v[4].duration,len(v[1][-1].notes),v[1][-1].operation),policy.DEFAULT.theme_continuations)
@@ -474,7 +495,7 @@ def phrase_candidates(source,analysis,incoming,part,harmony,library,rhythms,mete
history=incoming.history
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)
if group:history=phrasing.remember(history,phrasing.heard(group,transform.operation,ending if group[-1].end==ending.hold_until else None,bar),bar)
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:
+12
View File
@@ -97,6 +97,17 @@ class HarmonicTreatment:
event: str = ''
resolution_event: str = ''
@dataclasses.dataclass(frozen=True)
class ExpressionContract:
id: str
kind: str
rule: str
events: tuple[Note, ...]
target: Note
chord: str
boundary: fractions.Fraction | None = None
preparation: Note | None = None
@dataclasses.dataclass(frozen=True)
class LeadIdentity:
id: str
@@ -273,6 +284,7 @@ class MelodicDraft:
source_reference: SourceReference | None = None
sustained_tails: tuple[SustainedTail, ...] = ()
enclosures: tuple[EnclosureContract, ...] = ()
expressions: tuple[ExpressionContract, ...] = ()
def rests(self):
cursor, result = self.start, []
for note in self.notes:
+76
View File
@@ -0,0 +1,76 @@
"""Versioned production vocabulary. This module never reads audition inputs."""
from __future__ import annotations
import contextlib
import contextvars
import functools
import hashlib
import json
from pathlib import Path
from fractions import Fraction as F
ROOT = Path(__file__).resolve().parents[1] / 'data' / 'composition'
ACTIVE = contextvars.ContextVar('composition_vocabulary', default=None)
def load(root=ROOT):
# Content, not just the directory, keys memoization. Long-lived individual
# role builds must observe edited production data without restarting Python.
names = ('roles', 'grammar', 'harmony', 'lead_ins', 'rhythms')
return _load(tuple((name, (Path(root)/(name+'.json')).read_text()) for name in names))
@functools.lru_cache(maxsize=16)
def _load(contents):
result = {}
for name, text in contents:
value = json.loads(text)
if value.get('version') != 1 or not value.get('provenance') or not value.get('license'):
raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary')
entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', []))))
if len({v['id'] for v in entries}) != len(entries):
raise ValueError(f'{name}: duplicate vocabulary ID')
result[name] = value
return result
@functools.lru_cache(maxsize=1)
def _default():
return {'data': load(), 'role': '', 'settings': {}}
def current():
return ACTIVE.get() or _default()
def data(name):
return current()['data'][name]
def role(name=None):
active = current()
name = name or active['role']
return {**active['data']['roles']['roles'].get(name, {}), **active['settings']}
@contextlib.contextmanager
def using(resources, name, settings=None):
token = ACTIVE.set({'data': resources, 'role': name, 'settings': settings or {}})
try:
yield
finally:
ACTIVE.reset(token)
def fingerprint(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def rhythm_cells(tempo):
limits = data('roles')['difficulty']
for cell in data('rhythms')['cells']:
difficulty = cell.get('difficulty')
if difficulty and tempo > limits[difficulty + '_max_bpm']:
continue
cursor, notes = F(), []
for value in cell['values']:
rest = value.startswith('r:')
duration = F(value[2:] if rest else value)
if duration <= 0:
raise ValueError(cell['id'] + ': nonpositive rhythm duration')
if not rest:
notes.append((cursor, cursor + duration))
cursor += duration
if difficulty and len(notes) > limits['burst_attacks']:
continue
yield cell['id'], tuple(notes), cursor