Sepearate Generation
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user