+31
-26
@@ -17,9 +17,12 @@ import generator.engraving.pipeline
|
||||
import generator.engraving.types
|
||||
import generator.parts
|
||||
import generator.pico_artwork
|
||||
import generator.playback
|
||||
import generator.performance
|
||||
import generator.score
|
||||
import generator.summary
|
||||
import generator.solo.export
|
||||
import platform
|
||||
import os
|
||||
|
||||
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
|
||||
|
||||
@@ -42,8 +45,8 @@ def parse_args() -> argparse.Namespace:
|
||||
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("--export-solo-xml", action="store_true", help="Export completed solo roles as comparison MusicXML, without recomposing.")
|
||||
parser.add_argument("--trace-level",choices=("summary","full"),default="summary",help="Diagnostic detail only; does not seed composition.")
|
||||
parser.add_argument("--profile-output",type=pathlib.Path,help="Write build and per-role elapsed timings (not a search timeout).")
|
||||
parser.add_argument("--trace-level", choices=("summary", "full"), default="summary", help="Diagnostic detail only; does not seed composition.")
|
||||
parser.add_argument("--profile-output", type=pathlib.Path, help="Write build and per-role elapsed timings (not a search timeout).")
|
||||
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):
|
||||
@@ -58,19 +61,22 @@ 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)
|
||||
composition_cache = getattr(args, 'composition_cache', None)
|
||||
if composition_cache:
|
||||
composition_cache=composition_cache.resolve()
|
||||
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()
|
||||
timings=[];stages=[];last_stage=started
|
||||
timings = []
|
||||
stages = []
|
||||
last_stage = started
|
||||
|
||||
def report(message):
|
||||
nonlocal last_stage
|
||||
now=time.monotonic()
|
||||
stages.append({'completed_before':message,'seconds':now-last_stage});last_stage=now
|
||||
now = time.monotonic()
|
||||
stages.append({'completed_before': message, 'seconds': now - last_stage})
|
||||
last_stage = now
|
||||
if progress:
|
||||
print(f'Assets [{time.monotonic() - started:.0f}s]: {message}', flush=True)
|
||||
|
||||
@@ -82,23 +88,23 @@ 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',
|
||||
composition_jobs=getattr(args,'composition_jobs',2), cache_dir=getattr(args,'composition_cache',None), progress=progress,timings=timings)
|
||||
if getattr(args,'trace_level','summary')=='summary':
|
||||
for path in (staged/'analysis').glob('*.roles'):
|
||||
roles=json.loads(path.read_text())
|
||||
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', composition_jobs=getattr(args, 'composition_jobs', 2), cache_dir=getattr(args, 'composition_cache', None), progress=progress, timings=timings)
|
||||
if getattr(args, 'trace_level', 'summary') == 'summary':
|
||||
for path in (staged / 'analysis').glob('*.roles'):
|
||||
roles = json.loads(path.read_text())
|
||||
for role in roles.values():
|
||||
trace=role.get('details',{}).get('selection_trace',{})
|
||||
trace.pop('decisions',None)
|
||||
path.write_text(json.dumps(roles,sort_keys=True,indent=2))
|
||||
trace = role.get('details', {}).get('selection_trace', {})
|
||||
trace.pop('decisions', None)
|
||||
path.write_text(json.dumps(roles, sort_keys=True, indent=2))
|
||||
if getattr(args, 'export_solo_xml', False):
|
||||
from generator.solo.export import export_tree
|
||||
export_tree(staged)
|
||||
generator.solo.export.export_tree(staged)
|
||||
generator.score.validate_generated_tree(staged)
|
||||
if args.check:
|
||||
generator.summary.validate_expected_performance_summary(pathlib.Path(__file__).resolve().parents[1] / 'tests' / 'fixtures' / 'performance_summary.json', staged / 'songs')
|
||||
report('compiling playback')
|
||||
generator.playback.compile_tree(staged)
|
||||
report('copying supplied recordings')
|
||||
song_ids = {generator.score.slugify(path.stem) for path in (data_dir / 'songs').glob('*.xml')}
|
||||
audio_assets = generator.performance.copy_audio(static_assets_dir, staged, song_ids)
|
||||
generator.performance.validate_recording_lengths(staged, audio_assets)
|
||||
report('engraving pages')
|
||||
validated_pages = generator.engraving.pipeline.engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, jobs=getattr(args, 'jobs', 1), progress=progress)
|
||||
report('rendering Pico titles')
|
||||
@@ -106,16 +112,15 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
report('validating score, page, and audio assets')
|
||||
generator.score.validate_generated_tree(staged, validated_pages)
|
||||
report('building catalog database')
|
||||
generator.catalog.build_catalog(staged)
|
||||
generator.catalog.build_catalog(staged, audio_assets=audio_assets)
|
||||
with modulus.filesystem.publish_directories([(staged, output_dir)]):
|
||||
pass
|
||||
report('publishing complete')
|
||||
profile_path=getattr(args,'profile_output',None)
|
||||
profile_path = getattr(args, 'profile_output', None)
|
||||
if profile_path:
|
||||
import platform,os
|
||||
profile_path.parent.mkdir(parents=True,exist_ok=True)
|
||||
profile_path.write_text(json.dumps({'total_seconds':time.monotonic()-started,'stages':stages,'roles':timings,
|
||||
'platform':platform.platform(),'cpus':os.cpu_count(),'workers':getattr(args,'composition_jobs',2)},indent=2))
|
||||
profile_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
profile_path.write_text(json.dumps({'total_seconds': time.monotonic() - started, 'stages': stages, 'roles': timings,
|
||||
'platform': platform.platform(), 'cpus': os.cpu_count(), 'workers': getattr(args, 'composition_jobs', 2)}, indent=2))
|
||||
print(f'Generated Product Vamp assets: {performance_count} performance scores.')
|
||||
if args.check:
|
||||
print('Generator check passed.')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from fractions import Fraction as F
|
||||
import fractions
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
@@ -13,39 +13,41 @@ import generator.fretboard
|
||||
import generator.model
|
||||
import generator.parts
|
||||
import generator.score
|
||||
from generator.arrange import form
|
||||
import generator.arrange.form
|
||||
import generator.arrange.pickup
|
||||
|
||||
|
||||
def harmonic_key(measure, previous):
|
||||
|
||||
def key(chord):
|
||||
return repr({k: chord.get(k) for k in ('root', 'kind', 'degrees', 'bass', 'inversion')}) if chord else ''
|
||||
active = generator.accompaniment.active_chord(measure, F(), previous)
|
||||
|
||||
active = generator.accompaniment.active_chord(measure, fractions.Fraction(), previous)
|
||||
return (key(active), tuple((generator.model.fraction(h['onset']), key(h)) for h in measure['harmonies']
|
||||
if generator.model.fraction(h['onset']) > 0), measure['duration'], measure['time_signature'])
|
||||
|
||||
|
||||
def audit(source, documents):
|
||||
"""Compare music, not generated IDs, at every reused form position."""
|
||||
authored = generator.score.source_staff(source)['measures']
|
||||
result = {}
|
||||
for part, document in documents.items():
|
||||
if part == 'vocal': continue
|
||||
sections = form.full_arrangement_sections(authored, part)
|
||||
if part == 'vocal':
|
||||
continue
|
||||
sections = generator.arrange.form.full_arrangement_sections(authored, part)
|
||||
measures = document['staves'][0]['measures']
|
||||
carried = generator.accompaniment.carried_chords(measures)
|
||||
seen, exceptions, offset, reused = {}, [], 0, 0
|
||||
for si, section in enumerate(sections):
|
||||
following = sections[si + 1] if si + 1 < len(sections) else None
|
||||
for j in range(section.measure_count):
|
||||
if section.role != 'accompaniment': continue
|
||||
m = measures[offset+j]
|
||||
if j == section.measure_count-1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
|
||||
if section.role != 'accompaniment':
|
||||
continue
|
||||
m = measures[offset + j]
|
||||
if j == section.measure_count - 1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
|
||||
exceptions.append({'measure': m['number'], 'reason': 'protected melody pickup'})
|
||||
continue
|
||||
key = (section.source_measure_index+j, repr(harmonic_key(m, carried[offset+j])))
|
||||
key = (section.source_measure_index + j, repr(harmonic_key(m, carried[offset + j])))
|
||||
signature = tuple((generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
|
||||
tuple((generator.model.pitch_number(p),p['tablature']['string'],p['tablature']['fret']) for p in n['pitches']))
|
||||
tuple((generator.model.pitch_number(p), p['tablature']['string'], p['tablature']['fret']) for p in n['pitches']))
|
||||
for n in generator.score.measure_notes(m))
|
||||
if key in seen:
|
||||
if signature != seen[key]:
|
||||
|
||||
@@ -28,10 +28,10 @@ def section_role(part_type: str, section_index: int) -> typing.Literal["accompan
|
||||
return "slash"
|
||||
if section_index == 2:
|
||||
return "melody"
|
||||
if section_index in {1, 3, 5, 6}:
|
||||
if section_index in {1, 3, 5}:
|
||||
return "accompaniment"
|
||||
return "rest"
|
||||
return "vocal" if section_index in {1, 5, 6} else "rest"
|
||||
return "vocal" if section_index in {1, 5} else "rest"
|
||||
|
||||
def full_arrangement_sections(measures: list[dict], part_type: str) -> list[ArrangementSection]:
|
||||
if not measures:
|
||||
@@ -49,7 +49,6 @@ def full_arrangement_sections(measures: list[dict], part_type: str) -> list[Arra
|
||||
("Acoustic Solo", body_start, body_measure_count, 3),
|
||||
("Electric Solo", body_start, body_measure_count, 4),
|
||||
("Vocal Melody", body_start, body_measure_count, 5),
|
||||
("Ending", edge_source_measure, edge_measure_count, 6),
|
||||
)
|
||||
return [
|
||||
ArrangementSection(label=label, source_measure_index=start, measure_count=count, section_index=index, role=section_role(part_type, index))
|
||||
|
||||
@@ -19,16 +19,18 @@ import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.score
|
||||
import generator.solo.arrangement
|
||||
import generator.comping
|
||||
import generator.vocabulary
|
||||
import generator.production
|
||||
|
||||
FINAL_SECTION_INDEX = 6
|
||||
FINAL_SECTION_INDEX = 5
|
||||
|
||||
def arrange_document(document: dict, part_type: str) -> dict:
|
||||
"""Canonical source-only template; solos are inserted by production roles."""
|
||||
from generator import comping, vocabulary
|
||||
document=generator.parts.prepare_document(document)
|
||||
with vocabulary.using(vocabulary.load(),part_type+'_accompaniment'):
|
||||
backing=comping.Canonical(document,part_type,'canonical') if part_type!='vocal' else None
|
||||
return _arrange_document(document,part_type,accompaniment=backing)
|
||||
document = generator.parts.prepare_document(document)
|
||||
with generator.vocabulary.using(generator.vocabulary.load(), part_type + '_accompaniment'):
|
||||
backing = generator.comping.Canonical(document, part_type, 'canonical') if part_type != 'vocal' else None
|
||||
return _arrange_document(document, part_type, accompaniment=backing)
|
||||
|
||||
def section_source(source: list[dict], section, next_section=None) -> list[dict]:
|
||||
result = copy.deepcopy(source)
|
||||
@@ -115,5 +117,4 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
|
||||
return result
|
||||
|
||||
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, composition_jobs=2, cache_dir=None, progress=False, timings=None) -> int:
|
||||
from generator.production import generate_tree
|
||||
return generate_tree(source_dir, output_dir, jobs=composition_jobs, cache_dir=cache_dir, progress=progress,timings=timings)
|
||||
return generator.production.generate_tree(source_dir, output_dir, jobs=composition_jobs, cache_dir=cache_dir, progress=progress, timings=timings)
|
||||
|
||||
+21
-3
@@ -6,6 +6,11 @@ import pathlib
|
||||
import re
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import sys
|
||||
if __name__ == '__main__' and __package__ is None:
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
|
||||
import generator.performance
|
||||
|
||||
APPLICATION_ID = 0x56414D50
|
||||
SONG_COLUMNS = """
|
||||
@@ -45,9 +50,20 @@ def metadata(path: pathlib.Path, root: pathlib.Path) -> dict:
|
||||
source_path=relative.as_posix(), source_json=text,
|
||||
measure_count=len(staff['measures']), total_pages=len(presentation['pages']), title_image=title_image, part=staff['name'])
|
||||
|
||||
def populate(connection: sqlite3.Connection, root: pathlib.Path) -> None:
|
||||
def populate(connection: sqlite3.Connection, root: pathlib.Path, audio_assets=None) -> None:
|
||||
if not root.is_dir():
|
||||
raise ValueError(f'{root}: score asset directory not found')
|
||||
if audio_assets is None:
|
||||
audio_assets = []
|
||||
for path in sorted(root.glob('sounds/*.wav')):
|
||||
if path.name not in ('beat1.wav', 'beat2.wav'):
|
||||
raise ValueError(f'{path}: unknown metronome sample')
|
||||
audio_assets.append(generator.performance.describe_wav(path, root))
|
||||
for path in sorted(root.glob('backing/*.wav')):
|
||||
match = re.fullmatch(r'([a-z0-9]+(?:_[a-z0-9]+)*)_(60|70|80|90|100|110|120)\.wav', path.name)
|
||||
if not match:
|
||||
raise ValueError(f'{path}: invalid backing filename')
|
||||
audio_assets.append(generator.performance.describe_wav(path, root, song_id=match[1], bpm=int(match[2])))
|
||||
folder = root / 'songs'
|
||||
if (folder.exists() or folder.is_symlink()) and not folder.is_dir():
|
||||
raise ValueError(f'{folder}: score asset directory not found')
|
||||
@@ -56,6 +72,8 @@ def populate(connection: sqlite3.Connection, root: pathlib.Path) -> None:
|
||||
connection.execute(f'PRAGMA application_id = {APPLICATION_ID}')
|
||||
connection.execute(f'CREATE TABLE songs ({SONG_COLUMNS}, part TEXT NOT NULL) STRICT')
|
||||
connection.execute('CREATE INDEX songs_menu ON songs(title, composer, part, id)')
|
||||
connection.execute('CREATE TABLE resources (id TEXT PRIMARY KEY, value TEXT NOT NULL CHECK(json_valid(value))) STRICT')
|
||||
connection.execute('INSERT INTO resources VALUES (?, ?)', ('audio', json.dumps(audio_assets)))
|
||||
for path in sorted(folder.rglob('*.json')):
|
||||
try:
|
||||
if path.parent != folder:
|
||||
@@ -67,7 +85,7 @@ def populate(connection: sqlite3.Connection, root: pathlib.Path) -> None:
|
||||
if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
|
||||
raise ValueError('catalog integrity check failed')
|
||||
|
||||
def build_catalog(root: pathlib.Path, destination: pathlib.Path | None = None) -> pathlib.Path:
|
||||
def build_catalog(root: pathlib.Path, destination: pathlib.Path | None = None, *, audio_assets=None) -> pathlib.Path:
|
||||
if not root.is_dir():
|
||||
raise ValueError(f'{root}: score asset directory not found')
|
||||
destination = destination or root / 'catalog.db'
|
||||
@@ -76,7 +94,7 @@ def build_catalog(root: pathlib.Path, destination: pathlib.Path | None = None) -
|
||||
staged = pathlib.Path(temporary) / 'catalog.db'
|
||||
connection = sqlite3.connect(staged)
|
||||
try:
|
||||
populate(connection, root)
|
||||
populate(connection, root, audio_assets)
|
||||
finally:
|
||||
connection.close()
|
||||
staged.replace(destination)
|
||||
|
||||
+55
-47
@@ -3,121 +3,129 @@ 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
|
||||
import fractions
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.parts
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import generator.solo.policy
|
||||
|
||||
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()}
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter']))
|
||||
classes = {d: (root + generator.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)
|
||||
full = generator.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)
|
||||
requirements = generator.fretboard.ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(allowed)), full.basses)
|
||||
options = generator.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)]
|
||||
options = [v for v in options if len(v) == size and generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in v)]
|
||||
if not options:
|
||||
raise ValueError(harmony.symbol(chord) + ': no legal sparse voicing preserving essential written degrees')
|
||||
raise ValueError(generator.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))
|
||||
rank = (generator.guitar.compactness(v), generator.solo.policy.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.policy = generator.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)):
|
||||
measures = generator.score.source_staff(document)['measures']
|
||||
self.first_index = generator.score.body_start_measure_index(measures)
|
||||
self.last_index = len(measures) - 1
|
||||
carried = generator.accompaniment.carried_chords(measures)
|
||||
for index in range(generator.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__())
|
||||
key = (index, tuple((generator.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'])})
|
||||
length = generator.model.fraction(source['duration'])
|
||||
boundaries = sorted({fractions.Fraction(), length, *(generator.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'])}
|
||||
source_attacks = {generator.model.fraction(n['onset']) for n in generator.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)
|
||||
chord = generator.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
|
||||
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))))
|
||||
+ (.15 * generator.guitar.movement(self.previous, v) if self.previous else 0)
|
||||
+ .1 * generator.guitar.compactness(v), generator.solo.policy.stable_rank(self.seed, (index, start, v))))
|
||||
self.previous = selected
|
||||
patterns = []
|
||||
for figure in vocabulary.data('accompaniment')['comping']:
|
||||
events = tuple((start+F(a), min(end, start+F(a)+F(d))) for a, d in figure['attacks'] if F(a) < duration)
|
||||
for figure in generator.vocabulary.data('accompaniment')['comping']:
|
||||
events = tuple((start + fractions.Fraction(a), min(end, start + fractions.Fraction(a) + fractions.Fraction(d))) for a, d in figure['attacks'] if fractions.Fraction(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):
|
||||
if not events and (not prior or not generator.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
|
||||
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']))
|
||||
patterns.append((cost, generator.solo.policy.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])
|
||||
_, _, 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()
|
||||
self.report.append({'source_index': index, 'onset': str(start), 'chord': generator.harmony.symbol(chord), 'figure': label, 'tones': len(selected), 'bass': bass,
|
||||
'fuller_voicing_reason': 'explicit written degrees/bass' if len(selected) > 3 else ''})
|
||||
notes, cursor = [], fractions.Fraction()
|
||||
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)
|
||||
for duration, written in generator.accompaniment.written_durations(b - a):
|
||||
pitches = generator.fretboard.chord_pitches(chord, positions) if positions else []
|
||||
n = generator.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
|
||||
notes.append(n)
|
||||
previous = n
|
||||
a += duration
|
||||
cursor = b
|
||||
for start, end, positions, chord in attacks:
|
||||
if cursor < start: emit(cursor, start)
|
||||
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)
|
||||
if cursor < length:
|
||||
emit(cursor, length)
|
||||
result = generator.parts.build_measure(source, 'canonical', 'rest', self.part, prior)
|
||||
result['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
|
||||
for note in notes: note['voice'] = 'voice_arranged'
|
||||
for note in notes:
|
||||
note['voice'] = 'voice_arranged'
|
||||
self.cache[key] = copy.deepcopy(result)
|
||||
return result
|
||||
|
||||
+24
-21
@@ -2,30 +2,33 @@
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from generator import musicxml, parts, production
|
||||
import pathlib
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.production
|
||||
import generator.solo.export
|
||||
|
||||
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('--solo-xml',type=Path,help='Also export a completed solo role as MusicXML.')
|
||||
parser.add_argument('--settings',type=Path)
|
||||
args=parser.parse_args()
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--song', type=pathlib.Path, required=True)
|
||||
parser.add_argument('--role', choices=generator.production.ROLES, required=True)
|
||||
parser.add_argument('--data-dir', type=pathlib.Path, default=pathlib.Path('data'))
|
||||
parser.add_argument('--output', type=pathlib.Path, required=True)
|
||||
parser.add_argument('--cache-dir', type=pathlib.Path)
|
||||
parser.add_argument('--solo-xml', type=pathlib.Path, help='Also export a completed solo role as MusicXML.')
|
||||
parser.add_argument('--settings', type=pathlib.Path)
|
||||
args = parser.parse_args()
|
||||
if args.solo_xml and not args.role.endswith('_solo'):
|
||||
parser.error('--solo-xml requires a solo role')
|
||||
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))
|
||||
source = generator.production.SourceAnalysis.read(generator.parts.prepare_document(generator.musicxml.load_musicxml(args.song)))
|
||||
start = time.monotonic()
|
||||
result = generator.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))
|
||||
if args.solo_xml:
|
||||
from generator.solo.export import write_role
|
||||
manifest=write_role(result,args.solo_xml)
|
||||
args.solo_xml.with_suffix('.manifest.solo').write_text(json.dumps(manifest,indent=2)+'\n')
|
||||
print(f'{args.role}: {time.monotonic()-start:.2f}s wall; cached={result["cache_hit"]}; role generation/cache lookup {result["seconds"]:.2f}s')
|
||||
manifest = generator.solo.export.write_role(result, args.solo_xml)
|
||||
args.solo_xml.with_suffix('.manifest.solo').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
print(f'{args.role}: {time.monotonic() - start:.2f}s wall; cached={result["cache_hit"]}; role generation/cache lookup {result["seconds"]:.2f}s')
|
||||
|
||||
if __name__=='__main__':main()
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -22,7 +22,7 @@ def renderer_identity(fonts, style) -> dict:
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
versions = {name: importlib.metadata.version(name) for name in ('Pillow', 'fonttools', 'aggdraw')}
|
||||
versions.update({name: PIL.features.version(name) for name in PIL.features.get_supported()})
|
||||
sources = [*root.joinpath('engraving').glob('*.py'), root / 'model.py', root / 'harmony.py', root / 'theme.py', root / 'palette.json']
|
||||
sources = [*root.joinpath('engraving').glob('*.py'), root / 'model.py', root / 'performance.py', root / 'harmony.py', root / 'theme.py', root / 'palette.json']
|
||||
return {'fonts_style': fonts.fingerprint(style), 'python': {'implementation': platform.python_implementation(), 'version': platform.python_version()}, 'dependencies': versions,
|
||||
'sources': {path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(sources)}}
|
||||
|
||||
@@ -36,7 +36,7 @@ def renderer_namespace(cache: pathlib.Path, renderer: str) -> pathlib.Path:
|
||||
return namespace
|
||||
|
||||
def score_fingerprint(document: dict, renderer: str) -> str:
|
||||
music = {key: value for key, value in document.items() if key != 'playback'}
|
||||
music = document
|
||||
return hashlib.sha256(json.dumps([renderer, music], sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
||||
|
||||
def notation_fingerprint(document: dict, renderer: str) -> str:
|
||||
@@ -61,7 +61,7 @@ def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_page
|
||||
return None
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
for index, page in enumerate(pages, 1):
|
||||
if not isinstance(page, dict) or set(page) != {'path', 'sha256'} or page['path'] != f'{index:04}.png' or not isinstance(page['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', page['sha256']):
|
||||
if not isinstance(page, dict) or set(page) != {'path', 'sha256', 'first_measure', 'measure_count', 'start_beat', 'end_beat'} or page['path'] != f'{index:04}.png' or not isinstance(page['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', page['sha256']):
|
||||
return None
|
||||
path = entry / page['path']
|
||||
if path.is_symlink() or not path.is_file():
|
||||
|
||||
@@ -22,6 +22,7 @@ import generator.engraving.raster
|
||||
import generator.engraving.types
|
||||
import generator.engraving.validation
|
||||
import generator.model
|
||||
import generator.performance
|
||||
|
||||
@dataclasses.dataclass
|
||||
class GroupResult:
|
||||
@@ -101,11 +102,11 @@ def clear_pages(destination):
|
||||
|
||||
def write_bundle(path, document, pages, fingerprint):
|
||||
entries = [{**page, 'path': path.with_suffix('.pages').name + '/' + page['path']} for page in pages]
|
||||
music = {key: value for key, value in document.items() if key != 'playback'}
|
||||
music = document
|
||||
bundle = {'music': music, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'style_sha256': fingerprint,
|
||||
'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.PAGE_HEIGHT, 'pages': entries}}
|
||||
if 'playback' in document:
|
||||
bundle['playback'] = document['playback']
|
||||
generator.performance.validate_pages(document, pages)
|
||||
bundle['performance'] = generator.performance.timing(document)
|
||||
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
|
||||
def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False):
|
||||
@@ -165,7 +166,7 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
|
||||
image.save(target, format='PNG')
|
||||
result.timings['raster'] += time.monotonic() - started
|
||||
result.renderedpages += 1
|
||||
pages.append({'path': target.name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
|
||||
pages.append({'path': target.name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest(), **generator.performance.page_timing(document, page)})
|
||||
scores[score_key] = (destination, pages)
|
||||
write_bundle(path, document, pages, fingerprint)
|
||||
result.scores.append((path, score_key, pages))
|
||||
@@ -210,6 +211,11 @@ def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.
|
||||
entry = cache_dir / 'fullscore' / score_key if cache_dir else None
|
||||
present = entry is not None and (entry.exists() or entry.is_symlink())
|
||||
pages = generator.engraving.cache.restore_pages(entry, destination, validated_pages) if entry else None
|
||||
if pages is not None:
|
||||
try:
|
||||
generator.performance.validate_pages(document, pages)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
pages = None
|
||||
if pages is None:
|
||||
rejected += int(present)
|
||||
missing += int(entry is not None and not present)
|
||||
|
||||
@@ -4,7 +4,7 @@ import dataclasses
|
||||
import fractions
|
||||
|
||||
import generator.model
|
||||
|
||||
import generator.score
|
||||
|
||||
def solo_source(staff):
|
||||
"""Source-form pickup context, shared by training and arranged choruses.
|
||||
@@ -14,11 +14,11 @@ def solo_source(staff):
|
||||
retain their ordinary initial-harmony checks. Explicit pickup harmony
|
||||
(including N.C.) still takes precedence in read().
|
||||
"""
|
||||
from generator.score import body_start_measure_index
|
||||
measures=staff['measures'];initial=None
|
||||
if body_start_measure_index(measures):
|
||||
initial=next((h for m in reversed(measures) for h in reversed(m['harmonies'])),None)
|
||||
return read(staff,initial=initial)
|
||||
measures = staff['measures']
|
||||
initial = None
|
||||
if generator.score.body_start_measure_index(measures):
|
||||
initial = next((h for m in reversed(measures) for h in reversed(m['harmonies'])), None)
|
||||
return read(staff, initial=initial)
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ChordSpan:
|
||||
|
||||
+27
-23
@@ -3,35 +3,39 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import contextvars
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
ACTIVE=contextvars.ContextVar('composition_reads',default=None)
|
||||
ACTIVE = contextvars.ContextVar('composition_reads', default=None)
|
||||
|
||||
|
||||
def _read(event,args):
|
||||
scope=ACTIVE.get()
|
||||
if scope is None or event!='open':return
|
||||
name,mode,flags=args
|
||||
if not isinstance(name,(str,bytes,os.PathLike)):return
|
||||
def _read(event, args):
|
||||
scope = ACTIVE.get()
|
||||
if scope is None or event != 'open':
|
||||
return
|
||||
name, mode, flags = args
|
||||
if not isinstance(name, (str, bytes, os.PathLike)):
|
||||
return
|
||||
# r+ and w+ can read; they must obey the same boundary as a plain read.
|
||||
if flags & os.O_WRONLY:return
|
||||
path=Path(os.fsdecode(name)).resolve()
|
||||
data,code,cache,reads=scope
|
||||
python_runtime=any(path.is_relative_to(Path(root).resolve()) for root in (sys.base_prefix,sys.prefix))
|
||||
allowed=path.is_relative_to(data) or path.is_relative_to(code) or python_runtime
|
||||
if cache and path.is_relative_to(cache):allowed=True
|
||||
if not allowed:raise ValueError('Forbidden composition input: '+str(path))
|
||||
if flags & os.O_WRONLY:
|
||||
return
|
||||
path = pathlib.Path(os.fsdecode(name)).resolve()
|
||||
data, code, cache, reads = scope
|
||||
python_runtime = any(path.is_relative_to(pathlib.Path(root).resolve()) for root in (sys.base_prefix, sys.prefix))
|
||||
allowed = path.is_relative_to(data) or path.is_relative_to(code) or python_runtime
|
||||
if cache and path.is_relative_to(cache):
|
||||
allowed = True
|
||||
if not allowed:
|
||||
raise ValueError('Forbidden composition input: ' + str(path))
|
||||
reads.add(str(path))
|
||||
|
||||
|
||||
sys.addaudithook(_read)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def composition(data,role,cache=None):
|
||||
reads=set()
|
||||
code=Path(__file__).resolve().parent
|
||||
token=ACTIVE.set((Path(data).resolve(),code,Path(cache).resolve()/role if cache else None,reads))
|
||||
try:yield reads
|
||||
finally:ACTIVE.reset(token)
|
||||
def composition(data, role, cache=None):
|
||||
reads = set()
|
||||
code = pathlib.Path(__file__).resolve().parent
|
||||
token = ACTIVE.set((pathlib.Path(data).resolve(), code, pathlib.Path(cache).resolve() / role if cache else None, reads))
|
||||
try:
|
||||
yield reads
|
||||
finally:
|
||||
ACTIVE.reset(token)
|
||||
|
||||
+2
-2
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import copy
|
||||
import fractions
|
||||
import typing
|
||||
import generator.fretboard
|
||||
import generator.pitch
|
||||
|
||||
MAX_INTEGER = (1 << 63) - 1
|
||||
NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th')
|
||||
@@ -206,8 +208,6 @@ def validate_document(document: dict) -> None:
|
||||
else:
|
||||
pitch_number(p)
|
||||
if staves[0].get('tablature'):
|
||||
import generator.fretboard
|
||||
import generator.pitch
|
||||
tab = p.get('tablature', {})
|
||||
string, fret = tab.get('string'), tab.get('fret')
|
||||
if type(string) is not int or type(fret) is not int or string not in dict(generator.fretboard.STANDARD_TUNING) or not generator.fretboard.MIN_FRET <= fret <= generator.fretboard.MAX_FRET:
|
||||
|
||||
@@ -521,7 +521,7 @@ class Importer:
|
||||
name = item.get('name', '')
|
||||
if name.startswith('vamp:'):
|
||||
self.fail(f'unsupported Vamp property {name!r}')
|
||||
metadata = {'title': root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem, 'composer': ' / '.join(c.text for c in root.findall('identification/creator') if c.get('type') == 'composer' and c.text)}
|
||||
metadata = {'title': root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem.replace('_', ' ').replace('-', ' ').title(), 'composer': ' / '.join(c.text for c in root.findall('identification/creator') if c.get('type') == 'composer' and c.text)}
|
||||
measures = []
|
||||
self.children(parts[0], {'measure'})
|
||||
for index, bar in enumerate(parts[0]):
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Page timing and supplied recordings. No note synthesis or audio transformation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import wave
|
||||
|
||||
import generator.model
|
||||
|
||||
TEMPOS = tuple(range(60, 121, 10))
|
||||
|
||||
def boundaries(document):
|
||||
result = [fractions.Fraction()]
|
||||
for measure in document['staves'][0]['measures']:
|
||||
result.append(result[-1] + generator.model.fraction(measure['duration']))
|
||||
return result
|
||||
|
||||
def page_timing(document, page):
|
||||
times = boundaries(document)
|
||||
start, end = page.systems[0].start, page.systems[-1].end
|
||||
first, last = times.index(start), times.index(end)
|
||||
return dict(first_measure=first + 1, measure_count=last - first, start_beat=generator.model.rational(start), end_beat=generator.model.rational(end))
|
||||
|
||||
def validate_pages(document, pages):
|
||||
times = boundaries(document)
|
||||
cursor = 0
|
||||
for page in pages:
|
||||
if not isinstance(page, dict):
|
||||
raise ValueError('invalid page descriptor')
|
||||
count = page.get('measure_count')
|
||||
if type(count) is not int or count <= 0 or page.get('first_measure') != cursor + 1 or cursor + count >= len(times):
|
||||
raise ValueError('invalid page measure coverage')
|
||||
if generator.model.fraction(page.get('start_beat')) != times[cursor] or generator.model.fraction(page.get('end_beat')) != times[cursor + count]:
|
||||
raise ValueError('page timing does not match its measures')
|
||||
cursor += count
|
||||
if cursor != len(times) - 1:
|
||||
raise ValueError('incomplete page measure coverage')
|
||||
|
||||
def timing(document):
|
||||
times = boundaries(document)
|
||||
measures = document['staves'][0]['measures']
|
||||
# Metronome accents follow each actual measure, including meter changes.
|
||||
clicks = []
|
||||
for start, measure in zip(times, measures):
|
||||
duration = generator.model.fraction(measure['duration'])
|
||||
beat = fractions.Fraction()
|
||||
while beat < duration:
|
||||
clicks.append(dict(beat=generator.model.rational(start + beat), downbeat=beat == 0))
|
||||
beat += 1
|
||||
return dict(song_id=document['metadata'].get('song_id', ''),
|
||||
duration=generator.model.rational(times[-1]),
|
||||
count_in_beats=4, clicks=clicks)
|
||||
|
||||
def describe_wav(path, root, *, song_id='', bpm=0):
|
||||
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root.resolve()):
|
||||
raise ValueError(f'{path}: expected a contained regular WAV')
|
||||
try:
|
||||
with wave.open(str(path), 'rb') as audio:
|
||||
channels, rate, frames = audio.getnchannels(), audio.getframerate(), audio.getnframes()
|
||||
if audio.getcomptype() != 'NONE' or audio.getsampwidth() != 2 or channels not in (1, 2) or not 8000 <= rate <= 192000 or frames <= 0:
|
||||
raise ValueError('expected nonempty mono/stereo PCM16 WAV')
|
||||
remaining = frames
|
||||
while remaining:
|
||||
count = min(remaining, 65536)
|
||||
if len(audio.readframes(count)) != count * channels * 2:
|
||||
raise ValueError('truncated WAV data')
|
||||
remaining -= count
|
||||
except (wave.Error, EOFError) as error:
|
||||
raise ValueError(f'{path}: invalid WAV: {error}') from error
|
||||
with path.open('rb') as source:
|
||||
digest = hashlib.file_digest(source, 'sha256').hexdigest()
|
||||
return dict(path=path.relative_to(root).as_posix(), sha256=digest, channels=channels, sample_rate=rate, frames=frames, song_id=song_id, bpm=bpm)
|
||||
|
||||
def copy_audio(static, destination, song_ids):
|
||||
entries = []
|
||||
inputs = [(static / 'sounds' / name, '', 0) for name in ('beat1.wav', 'beat2.wav')]
|
||||
for path in sorted((static / 'backing').glob('*.wav')):
|
||||
match = re.fullmatch(r'([a-z0-9]+(?:_[a-z0-9]+)*)_(60|70|80|90|100|110|120)\.wav', path.name)
|
||||
if not match:
|
||||
raise ValueError(f'{path}: unsupported backing filename or tempo')
|
||||
if match[1] not in song_ids:
|
||||
continue
|
||||
inputs.append((path, match[1], int(match[2])))
|
||||
for source, song_id, bpm in inputs:
|
||||
description = describe_wav(source, static, song_id=song_id, bpm=bpm)
|
||||
target = destination / description['path']
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, target)
|
||||
entries.append(description)
|
||||
return entries
|
||||
|
||||
def validate_recording_lengths(root, entries):
|
||||
durations = {}
|
||||
for path in sorted((root / 'songs').glob('*.json')):
|
||||
document = json.loads(path.read_text())
|
||||
performance = timing(document)
|
||||
song_id = performance['song_id']
|
||||
duration = generator.model.fraction(performance['duration']) + performance['count_in_beats']
|
||||
if song_id in durations and durations[song_id] != duration:
|
||||
raise ValueError(f'{song_id}: parts disagree about performance duration')
|
||||
durations[song_id] = duration
|
||||
for entry in entries:
|
||||
if entry['song_id']:
|
||||
seconds = fractions.Fraction(entry['frames'], entry['sample_rate'])
|
||||
if seconds < durations[entry['song_id']] * 60 / entry['bpm']:
|
||||
raise ValueError(f"{entry['path']}: backing recording is shorter than count-in and score")
|
||||
@@ -20,7 +20,7 @@ TITLE_HEADER = struct.pack('<4sHH', b'VPTI', TITLE_WIDTH, TITLE_HEIGHT)
|
||||
TITLE_BYTES = len(TITLE_HEADER) + TITLE_WIDTH * TITLE_HEIGHT // 2
|
||||
MAX_TITLES = 8192
|
||||
LABELS = ('VAMP', 'CONNECTING', 'OFFLINE', 'BUSY', 'READY', 'LOADING', 'WIFI', 'ERROR', 'PAGE', 'Acoustic', 'Electric', 'Vocal', 'UNAVAILABLE',
|
||||
'Main Menu', 'Performance', 'Settings', 'Playback', 'Metronome', 'Tempo', 'Back', 'On', 'Off', 'BPM')
|
||||
'Main Menu', 'Performance', 'Settings', 'Backing', 'Metronome', 'Tempo', 'Back', 'On', 'Off', 'BPM', 'Manual', 'Preparing', 'Count-in', 'Playing', 'Sustain', 'Hold to exit')
|
||||
SPRITES = LABELS + tuple('0123456789/')
|
||||
DIGIT_WIDTH = 22
|
||||
DEFAULT_FONT_ROOT = pathlib.Path(__file__).resolve().parents[1] / 'package/assets/fonts/petaluma'
|
||||
|
||||
+1
-1
@@ -89,6 +89,6 @@ def concert_document(document: dict, *, octave_transposing: bool = False) -> dic
|
||||
|
||||
def sounding_pitch(pitch: dict, state: dict):
|
||||
if 'unpitched' in pitch:
|
||||
raise ValueError('unpitched notes have no fretted or generated_tone interpretation')
|
||||
raise ValueError('unpitched notes have no fretted pitch interpretation')
|
||||
transpose = state['transpose']
|
||||
return generator.model.pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change']
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import array
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import sys
|
||||
import wave
|
||||
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
|
||||
SAMPLE_RATE = 48000
|
||||
PROFILE = 'soft_struck'
|
||||
MAX_EVENTS = 1000000
|
||||
|
||||
class PlaybackUnavailable(ValueError):
|
||||
pass
|
||||
|
||||
def sounding_pitch(tone: dict, state: dict) -> fractions.Fraction:
|
||||
if 'unpitched' in tone:
|
||||
raise PlaybackUnavailable('unpitched notation has no generated_tone interpretation')
|
||||
value = generator.pitch.sounding_pitch(tone, state)
|
||||
frequency = 440 * 2 ** (float(value - 69) / 12)
|
||||
if not 20 <= frequency <= 18000:
|
||||
raise PlaybackUnavailable(f'sounding pitch {value} is outside the generated_tone range (20-18000 Hz)')
|
||||
return value
|
||||
|
||||
def compile_timeline(document: dict) -> dict:
|
||||
events, notes, linked = [], {}, {}
|
||||
absolute = fractions.Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
for voice in measure['voices']:
|
||||
state = {key: measure[key] for key in ('transpose', 'clef', 'staff_details')}
|
||||
changes = iter(sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset'])))
|
||||
change = next(changes, None)
|
||||
for note in voice['notes']:
|
||||
onset = generator.model.fraction(note['onset'])
|
||||
while change is not None and generator.model.fraction(change['onset']) <= onset:
|
||||
state.update({key: change[key] for key in state if key in change})
|
||||
change = next(changes, None)
|
||||
notes[note['id']] = (note, absolute + onset, dict(state), voice['id'])
|
||||
absolute += generator.model.fraction(measure['duration'])
|
||||
adjustments = {}
|
||||
insertions = {}
|
||||
lanes = {}
|
||||
for item in notes.values():
|
||||
lanes.setdefault(item[3], []).append(item)
|
||||
for lane in lanes.values():
|
||||
pending = []
|
||||
previous = None
|
||||
for note, onset, state, voice in lane:
|
||||
if note.get('grace'):
|
||||
pending.append((note, onset, state, voice))
|
||||
continue
|
||||
if pending:
|
||||
cursor = onset
|
||||
specified = [n['grace'] for n, _, _, _ in pending]
|
||||
modes = {key for g in specified for key in ('make-time', 'steal-time-previous', 'steal-time-following') if key in g}
|
||||
if len(modes) > 1:
|
||||
raise PlaybackUnavailable('grace group mixes conflicting timing modes')
|
||||
mode = next(iter(modes), 'steal-time-following')
|
||||
if mode == 'make-time':
|
||||
lengths = [generator.model.fraction(g.get(mode, generator.model.rational(0))) for g in specified]
|
||||
total = sum(lengths, fractions.Fraction())
|
||||
insertions[onset] = max(insertions.get(onset, fractions.Fraction()), total)
|
||||
else:
|
||||
donor = previous[0] if mode == 'steal-time-previous' and previous else note if mode == 'steal-time-following' else None
|
||||
if donor is None:
|
||||
raise PlaybackUnavailable('grace notes have no preceding duration to steal')
|
||||
percentages = [generator.model.fraction(g[mode]) if mode in g else fractions.Fraction(25, len(pending)) for g in specified]
|
||||
if not 0 < sum(percentages) < 100:
|
||||
raise PlaybackUnavailable('grace group must steal between zero and 100 percent')
|
||||
lengths = [generator.model.fraction(donor['duration']) * amount / 100 for amount in percentages]
|
||||
total = sum(lengths, fractions.Fraction())
|
||||
delay, extension = adjustments.get(donor['id'], (fractions.Fraction(), fractions.Fraction()))
|
||||
if mode == 'steal-time-previous':
|
||||
cursor -= total
|
||||
else:
|
||||
delay += total
|
||||
extension -= total
|
||||
if generator.model.fraction(donor['duration']) + extension <= 0 or any(generator.model.fraction(p['duration']) + extension <= 0 for p in donor['pitches']):
|
||||
raise PlaybackUnavailable(f'{donor["id"]}: grace timing consumes a donor or chord tone')
|
||||
adjustments[donor['id']] = (delay, extension)
|
||||
for item, length in zip(pending, lengths):
|
||||
value, _, pitch_state, lane_id = item
|
||||
adjustments[value['id']] = (cursor - item[1], length)
|
||||
cursor += length
|
||||
pending = []
|
||||
previous = (note, onset)
|
||||
if pending:
|
||||
raise PlaybackUnavailable('trailing grace group has no following anchor')
|
||||
for identifier, (note, onset, state, voice) in notes.items():
|
||||
if note.get('cue'):
|
||||
continue
|
||||
delay, extension = adjustments.get(identifier, (fractions.Fraction(), fractions.Fraction()))
|
||||
performed = onset + delay + sum((length for time, length in insertions.items() if time < onset or time == onset and not note.get('grace')), fractions.Fraction())
|
||||
tremolo = note.get('tremolo')
|
||||
if tremolo and tremolo['type'] == 'stop':
|
||||
continue
|
||||
for pitch in note['pitches']:
|
||||
if pitch.get('cue'):
|
||||
continue
|
||||
length = generator.model.fraction(pitch['duration']) + extension
|
||||
if not note.get('grace'):
|
||||
length += sum((amount for time, amount in insertions.items() if onset < time < onset + generator.model.fraction(pitch['duration'])), fractions.Fraction())
|
||||
if length <= 0:
|
||||
raise PlaybackUnavailable(f'{identifier}: grace timing consumes a chord tone')
|
||||
value = sounding_pitch(pitch, state)
|
||||
if pitch.get('tie_from'):
|
||||
previous = linked.get(pitch['tie_from'])
|
||||
if previous is None or generator.model.fraction(previous['onset']) + generator.model.fraction(previous['duration']) != performed or generator.model.fraction(previous['pitch']) != value:
|
||||
raise PlaybackUnavailable(f'{identifier}: tie has inconsistent performed timing or pitch')
|
||||
previous['duration'] = generator.model.rational(generator.model.fraction(previous['duration']) + length)
|
||||
linked[pitch['id']] = previous
|
||||
continue
|
||||
if tremolo and tremolo['marks']:
|
||||
if pitch.get('tie_to'):
|
||||
raise PlaybackUnavailable('tied measured tremolo is not yet qualified for playback')
|
||||
written = pitch['written']
|
||||
step = min(fractions.Fraction(1), generator.model.TYPE_LENGTHS[written['type']]) * generator.model.fraction(written.get('ratio', generator.model.rational(1))) / 2 ** tremolo['marks']
|
||||
if tremolo['type'] == 'start':
|
||||
partner = notes[tremolo['partner']]
|
||||
if partner[1] != onset + generator.model.fraction(note['duration']) or len(partner[0]['pitches']) != len(note['pitches']):
|
||||
raise PlaybackUnavailable('paired tremolo requires contiguous equal-size chords')
|
||||
step *= 2
|
||||
length += generator.model.fraction(partner[0]['pitches'][note['pitches'].index(pitch)]['duration'])
|
||||
cursor, attack = fractions.Fraction(), 0
|
||||
while cursor < length:
|
||||
amount = value
|
||||
if tremolo['type'] == 'start' and attack % 2:
|
||||
other = partner[0]['pitches'][note['pitches'].index(pitch)]
|
||||
amount = sounding_pitch(other, partner[2])
|
||||
events.append({'id': f'{pitch["id"]}_{attack}', 'pitch': generator.model.rational(amount), 'onset': generator.model.rational(performed + cursor), 'duration': generator.model.rational(min(step, length - cursor))})
|
||||
cursor += step
|
||||
attack += 1
|
||||
if len(events) > MAX_EVENTS:
|
||||
raise PlaybackUnavailable('playback exceeds one million tone events')
|
||||
else:
|
||||
event = {'id': pitch['id'], 'pitch': generator.model.rational(value), 'onset': generator.model.rational(performed), 'duration': generator.model.rational(length)}
|
||||
events.append(event)
|
||||
linked[pitch['id']] = event
|
||||
if len(events) > MAX_EVENTS:
|
||||
raise PlaybackUnavailable('playback exceeds one million tone events')
|
||||
events.sort(key=lambda e: (generator.model.fraction(e['onset']), e['id']))
|
||||
return {'available': True, 'duration': generator.model.rational(absolute + sum(insertions.values(), fractions.Fraction())), 'events': events}
|
||||
|
||||
def write_pcm(path: pathlib.Path, values) -> str:
|
||||
samples = array.array('h', (round(max(-1, min(1, sample)) * 32767) for sample in values))
|
||||
if sys.byteorder != 'little':
|
||||
samples.byteswap()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with wave.open(str(path), 'wb') as output:
|
||||
output.setparams((1, 2, SAMPLE_RATE, 0, 'NONE', 'not compressed'))
|
||||
output.writeframes(samples.tobytes())
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
def generate_tone(root: pathlib.Path, pitch: fractions.Fraction) -> dict:
|
||||
frequency = 440 * 2 ** (float(pitch - 69) / 12)
|
||||
period = fractions.Fraction(frequency / SAMPLE_RATE).limit_denominator(SAMPLE_RATE)
|
||||
loop_length, cycles = period.denominator, period.numerator
|
||||
attack = 12000
|
||||
name = hashlib.sha256(f'{PROFILE}:{pitch}'.encode('ascii')).hexdigest()[:24]
|
||||
relative = f'audio/{name}.wav'
|
||||
harmonics = [h for h in range(1, 7) if frequency * h < SAMPLE_RATE * 0.45]
|
||||
|
||||
def sample(frame):
|
||||
phase = 2 * math.pi * cycles * frame / loop_length
|
||||
envelope = min(1, frame / 240) * (0.18 + 0.82 * (1 - min(1, frame / attack)) ** 3)
|
||||
return 0.3 * envelope * sum(math.sin(phase * h) / (h * h) for h in harmonics)
|
||||
|
||||
checksum = write_pcm(root / relative, (sample(i) for i in range(attack + loop_length)))
|
||||
return {'id': name, 'path': relative, 'sha256': checksum, 'sample_rate': SAMPLE_RATE, 'frames': attack + loop_length, 'loop_start': attack, 'loop_end': attack + loop_length}
|
||||
|
||||
def compile_tree(root: pathlib.Path) -> None:
|
||||
tones = {}
|
||||
release = {'path': 'audio/release.wav', 'sample_rate': SAMPLE_RATE, 'frames': 2400}
|
||||
release['sha256'] = write_pcm(root / release['path'], ((1 - i / 2399) ** 2 for i in range(2400)))
|
||||
for path in sorted(root.rglob('*.json')):
|
||||
document = json.loads(path.read_text(encoding='utf-8'))
|
||||
try:
|
||||
playback = compile_timeline(document)
|
||||
used = {}
|
||||
for event in playback['events']:
|
||||
event['end'] = generator.model.rational(generator.model.fraction(event['onset']) + generator.model.fraction(event.pop('duration')))
|
||||
pitch = generator.model.fraction(event.pop('pitch'))
|
||||
if pitch not in tones:
|
||||
tones[pitch] = generate_tone(root, pitch)
|
||||
tone = tones[pitch]
|
||||
event['sample'] = tone['id']
|
||||
used[tone['id']] = tone
|
||||
playback.update({'samples': list(used.values()), 'release': release, 'profile': PROFILE})
|
||||
except PlaybackUnavailable as error:
|
||||
playback = {'available': False, 'reason': str(error)}
|
||||
document['playback'] = playback
|
||||
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
|
||||
def validate_assets(playback: dict, root: pathlib.Path, cache: dict | None = None) -> None:
|
||||
if not isinstance(playback, dict) or type(playback.get('available')) is not bool:
|
||||
raise ValueError('playback manifest requires boolean availability')
|
||||
if not playback['available']:
|
||||
if not isinstance(playback.get('reason'), str) or not playback['reason']:
|
||||
raise ValueError('unavailable playback must explain why')
|
||||
return
|
||||
cache = {} if cache is None else cache
|
||||
|
||||
def check_sample(value):
|
||||
name = value['path']
|
||||
relative = pathlib.Path(name)
|
||||
if not name.startswith('audio/') or relative.is_absolute() or relative.suffix != '.wav' or any(p in ('', '.', '..') for p in name.split('/')) or '\\' in name or '\x00' in name:
|
||||
raise ValueError('invalid generated audio path')
|
||||
path = (root / relative).resolve()
|
||||
if not path.is_relative_to(root.resolve()):
|
||||
raise ValueError('generated audio escapes asset root')
|
||||
key = (str(path), value['sha256'], value['frames'], value['sample_rate'])
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
if value['sample_rate'] != SAMPLE_RATE or not 0 < value['frames'] <= 96000:
|
||||
raise ValueError('invalid generated audio rate/length')
|
||||
if path.stat().st_size != 44 + value['frames'] * 2 or hashlib.sha256(path.read_bytes()).hexdigest() != value['sha256']:
|
||||
raise ValueError('generated audio checksum mismatch')
|
||||
with wave.open(str(path)) as audio:
|
||||
pcm = audio.readframes(value['frames'])
|
||||
if (audio.getnchannels(), audio.getsampwidth(), audio.getframerate(), audio.getnframes()) != (1, 2, SAMPLE_RATE, value['frames']) or len(pcm) != value['frames'] * 2:
|
||||
raise ValueError('invalid or truncated generated PCM WAV')
|
||||
samples = array.array('h')
|
||||
samples.frombytes(pcm)
|
||||
if sys.byteorder != 'little':
|
||||
samples.byteswap()
|
||||
if min(samples) < -32767:
|
||||
raise ValueError('generated PCM exceeds unit range')
|
||||
cache[key] = samples
|
||||
return samples
|
||||
|
||||
duration = generator.model.fraction(playback['duration'])
|
||||
if duration <= 0 or len(playback['events']) > MAX_EVENTS:
|
||||
raise ValueError('invalid playback timeline length')
|
||||
release = check_sample(playback['release'])
|
||||
if not 1 < playback['release']['frames'] <= SAMPLE_RATE // 2:
|
||||
raise ValueError('invalid release envelope length')
|
||||
if release[0] != 32767 or release[-1] != 0 or any(a < b for a, b in zip(release, release[1:])):
|
||||
raise ValueError('release envelope must decrease from one to zero')
|
||||
if len(playback['samples']) > 512 or sum(s['frames'] for s in playback['samples']) > 16 * 1024 * 1024:
|
||||
raise ValueError('generated playback exceeds sample memory budget')
|
||||
samples = {}
|
||||
for sample in playback['samples']:
|
||||
if not sample['id'] or sample['id'] in samples or not 0 <= sample['loop_start'] < sample['loop_end'] == sample['frames']:
|
||||
raise ValueError('invalid sample identity or loop')
|
||||
check_sample(sample)
|
||||
samples[sample['id']] = sample
|
||||
previous = fractions.Fraction()
|
||||
identities = set()
|
||||
for event in playback['events']:
|
||||
onset, end = generator.model.fraction(event['onset']), generator.model.fraction(event['end'])
|
||||
if event['id'] in identities or event['sample'] not in samples or not previous <= onset < end <= duration:
|
||||
raise ValueError('invalid playback event identity, reference or timing')
|
||||
identities.add(event['id'])
|
||||
previous = onset
|
||||
+231
-195
@@ -12,302 +12,338 @@ import functools
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pathlib
|
||||
import tempfile
|
||||
import time
|
||||
from fractions import Fraction as F
|
||||
import fractions
|
||||
|
||||
from generator import accompaniment, comping, fretboard, model, musicxml, parts, score, vocabulary
|
||||
from generator.arrange import form, generate, pickup
|
||||
from generator.solo import analysis, composer, notation, report, learned
|
||||
from generator.solo import authored as repertoire
|
||||
import generator.accompaniment
|
||||
import generator.comping
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import generator.arrange.form
|
||||
import generator.arrange.generate
|
||||
import generator.arrange.pickup
|
||||
import generator.solo.analysis
|
||||
import generator.solo.composer
|
||||
import generator.solo.notation
|
||||
import generator.solo.report
|
||||
import generator.solo.learned
|
||||
import generator.solo.authored
|
||||
import generator.input_audit
|
||||
import generator.solo.arrangement
|
||||
|
||||
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)
|
||||
staff = generator.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')}
|
||||
m['id'], m['number'] = f'source_{i}', str(i + 1)
|
||||
staff['tuplets'] = generator.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)
|
||||
melodic = generator.solo.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', 'time_signature', '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()
|
||||
source = generator.score.source_staff(document)['measures']
|
||||
sections = generator.arrange.form.full_arrangement_sections(source, part)
|
||||
result, cursor = [], fractions.Fraction()
|
||||
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())
|
||||
duration = sum((generator.model.fraction(m['duration']) for m in source[section.source_measure_index:section.source_measure_index + section.measure_count]), fractions.Fraction())
|
||||
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 section.role in ('vocal', 'melody') and generator.arrange.pickup.enters(section, source) and result:
|
||||
attacks = [generator.model.fraction(n['onset']) for n in generator.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
|
||||
start=cursor
|
||||
if section.role == 'slash' and score.body_start_measure_index(source) and result:
|
||||
start = cursor - generator.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
|
||||
start = cursor
|
||||
if section.role == 'slash' and generator.score.body_start_measure_index(source) and result:
|
||||
# Allocate the source's complete pickup bar before composition. This
|
||||
# does not inspect either generated solo; a role may leave it silent.
|
||||
start=cursor-model.fraction(source[0]['duration'])
|
||||
a,_,old=result[-1];result[-1]=(a,start,old)
|
||||
result.append((start, end, part+'_'+role))
|
||||
start = cursor - generator.model.fraction(source[0]['duration'])
|
||||
a, _, old = result[-1]
|
||||
result[-1] = (a, start, old)
|
||||
result.append((start, end, part + '_' + role))
|
||||
cursor = end
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def dependencies(role, resources):
|
||||
names = ('harmony', 'lead_ins') if role.endswith('_solo') else ('accompaniment',) if role.endswith('_accompaniment') else ()
|
||||
names = ['harmony', 'lead_ins'] if role.endswith('_solo') else ['accompaniment'] 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
|
||||
root = pathlib.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'))
|
||||
paths += list((root / 'arrange').glob('*.py'))
|
||||
if role.endswith('_solo'):
|
||||
paths += list((root/'solo').glob('*.py'))
|
||||
paths += [root/'harmonic_time.py', root/'musicxml.py',root/'training_io.py']
|
||||
paths += list((root / 'solo').glob('*.py'))
|
||||
paths += [root / 'harmonic_time.py', root / 'musicxml.py', root / 'training_io.py']
|
||||
elif role.endswith('_accompaniment'):
|
||||
paths += [root/n for n in ('comping.py', 'accompaniment.py', 'guitar.py')]
|
||||
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))])
|
||||
|
||||
paths += [root / 'guitar.py', root / 'melody.py']
|
||||
return generator.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):
|
||||
return {'model': hashlib.sha256((directory/'models'/'solo.json').read_bytes()).hexdigest()} if role.endswith('_solo') else {}
|
||||
return {'model': hashlib.sha256((directory / 'models' / 'solo.json').read_bytes()).hexdigest()} if role.endswith('_solo') else {}
|
||||
|
||||
@functools.lru_cache(maxsize=4)
|
||||
def _solo_model(directory, fingerprint):
|
||||
return learned.Model.load(Path(directory)/'models'/'solo.json')
|
||||
|
||||
return generator.solo.learned.Model.load(pathlib.Path(directory) / 'models' / 'solo.json')
|
||||
|
||||
def _mask(document, allowed):
|
||||
"""Apply source-defined role exclusions, without observing another part."""
|
||||
absolute = F()
|
||||
absolute = fractions.Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
length = model.fraction(measure['duration'])
|
||||
length = generator.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:
|
||||
start = absolute + generator.model.fraction(n['onset'])
|
||||
end = start + generator.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'])
|
||||
note['onset'] = generator.model.rational(a - absolute)
|
||||
for pitch in note['pitches']:
|
||||
pitch.pop('tie_from', None)
|
||||
generator.arrange.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',[]))
|
||||
|
||||
staff = document['staves'][0]
|
||||
staff['tuplets'] = generator.arrange.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()
|
||||
notes = sorted(notes, key=lambda n: generator.model.fraction(n['onset']))
|
||||
result = []
|
||||
cursor = fractions.Fraction()
|
||||
|
||||
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 duration, written in generator.accompaniment.written_durations(end - cursor):
|
||||
result.append(generator.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'])
|
||||
start = generator.model.fraction(n['onset'])
|
||||
if start < cursor:
|
||||
raise ValueError(prefix + ': overlapping independently generated roles')
|
||||
rests(start)
|
||||
n['voice'] = voice
|
||||
result.append(n)
|
||||
cursor = start + generator.model.fraction(n['duration'])
|
||||
rests(length)
|
||||
return result
|
||||
|
||||
|
||||
def generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
from generator.input_audit import composition
|
||||
started=time.monotonic()
|
||||
with composition(data_dir,role,cache_dir) as reads:
|
||||
result=_generate_role(source,role,data_dir,settings,cache_dir)
|
||||
started = time.monotonic()
|
||||
with generator.input_audit.composition(data_dir, role, cache_dir) as reads:
|
||||
result = _generate_role(source, role, data_dir, settings, cache_dir)
|
||||
# Observed reads are diagnostics, never cache identity or musical seeds.
|
||||
result['trace']={**result['trace'],'input_boundary':'production data (including explicit authored repertoire), generator code, Python runtime, own role cache'}
|
||||
result['observed_reads']=sorted(reads)
|
||||
result['seconds']=time.monotonic()-started
|
||||
result['trace'] = {**result['trace'], 'input_boundary': 'production data (including explicit authored repertoire), generator code, Python runtime, own role cache'}
|
||||
result['observed_reads'] = sorted(reads)
|
||||
result['seconds'] = time.monotonic() - started
|
||||
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 {}
|
||||
registered=repertoire.lookup(source,role.removesuffix('_solo'),data_dir) if role.endswith('_solo') and not settings.get('predict',False) else None
|
||||
algorithm=VERSION+'/'+learned.VERSION if role.endswith('_solo') else VERSION
|
||||
inputs={'version':algorithm,'source':source.musical_key,'role':role,'settings':settings,
|
||||
'vocabulary':dependencies(role,resources),'catalogs':({'authored':registered['sha256'],'id':registered['id'],'pitch_convention':registered['pitch_convention']} if registered else _resource_key(data_dir,role)),'code':_code_key(role)}
|
||||
if role not in ROLES:
|
||||
raise ValueError('Unknown generation role: ' + role)
|
||||
data_dir = pathlib.Path(data_dir)
|
||||
resources = generator.vocabulary.load(data_dir / 'composition')
|
||||
settings = settings or {}
|
||||
registered = generator.solo.authored.lookup(source, role.removesuffix('_solo'), data_dir) if role.endswith('_solo') and not settings.get('predict', False) else None
|
||||
algorithm = VERSION + '/' + generator.solo.learned.VERSION if role.endswith('_solo') else VERSION
|
||||
inputs = {'version': algorithm, 'source': source.musical_key, 'role': role, 'settings': settings,
|
||||
'vocabulary': dependencies(role, resources), 'catalogs': ({'authored': registered['sha256'], 'id': registered['id'], 'pitch_convention': registered['pitch_convention']} if registered else _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
|
||||
key = generator.vocabulary.fingerprint({'inputs': inputs, 'notation': source.document_json})
|
||||
path = pathlib.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':algorithm,'code':inputs['code'],'cache_key':key}
|
||||
seed=vocabulary.fingerprint({'source':source.musical_key,'role':role,'settings':settings,'data':inputs['vocabulary'],'version':algorithm})
|
||||
details={}; solo_report=''; composition_timings={}
|
||||
with vocabulary.using(resources,role,settings|{'choice_seed':seed}):
|
||||
entry = json.loads(path.read_text())
|
||||
value = entry['result']
|
||||
if entry['key'] == key and entry['digest'] == generator.vocabulary.fingerprint(value):
|
||||
generator.model.validate_document(value['document'])
|
||||
generator.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': generator.vocabulary.fingerprint(inputs['vocabulary']), 'settings': settings, 'algorithm': algorithm, 'code': inputs['code'], 'cache_key': key}
|
||||
seed = generator.vocabulary.fingerprint({'source': source.musical_key, 'role': role, 'settings': settings, 'data': inputs['vocabulary'], 'version': algorithm})
|
||||
details = {}
|
||||
solo_report = ''
|
||||
composition_timings = {}
|
||||
with generator.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
|
||||
si=3 if part=='acoustic' else 4
|
||||
first,count,offset,length=regions(document)[si]
|
||||
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'slash'})
|
||||
si = 3 if part == 'acoustic' else 4
|
||||
first, count, offset, length = generator.solo.arrangement.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]
|
||||
start,end=next((a-offset,b-offset) for a,b,r in windows(document,part) if r==role)
|
||||
notation_first=first
|
||||
if start<0:
|
||||
authored = generator.score.source_staff(document)['measures']
|
||||
section = generator.arrange.form.full_arrangement_sections(authored, part)[si]
|
||||
effective = generator.arrange.generate.section_source(authored, section, generator.arrange.form.full_arrangement_sections(authored, part)[si + 1])
|
||||
template = copy.deepcopy(effective[section.source_measure_index:section.source_measure_index + count])
|
||||
initial = generator.accompaniment.carried_chords(effective)[section.source_measure_index]
|
||||
start, end = next((a - offset, b - offset) for a, b, r in windows(document, part) if r == role)
|
||||
notation_first = first
|
||||
if start < 0:
|
||||
# The preceding arrangement harmony comes from the source form. Do
|
||||
# not invent harmony from a comparison score's unlabelled lead-in.
|
||||
template.insert(0,copy.deepcopy(result['staves'][0]['measures'][first-1]))
|
||||
notation_first-=1
|
||||
initial=accompaniment.carried_chords(result['staves'][0]['measures'])[first-1]
|
||||
template.insert(0, copy.deepcopy(result['staves'][0]['measures'][first - 1]))
|
||||
notation_first -= 1
|
||||
initial = generator.accompaniment.carried_chords(result['staves'][0]['measures'])[first - 1]
|
||||
if registered:
|
||||
solo=repertoire.realize(registered,analysis.analyze(document),template,part,start,end,initial)
|
||||
solo = generator.solo.authored.realize(registered, generator.solo.analysis.analyze(document), template, part, start, end, initial)
|
||||
else:
|
||||
solo_model=_solo_model(str(data_dir),inputs['catalogs']['model'])
|
||||
solo=composer.compose(analysis.analyze(document),template,part,end,
|
||||
initial=initial,learned_model=solo_model,
|
||||
expressions_enabled=vocabulary.role().get('expressions',True),start=start)
|
||||
composition_timings={k:v for k,v in solo.diagnostics.items() if k.endswith('_seconds')}
|
||||
relative=tuple(dataclasses.replace(n,start=n.start-start,end=n.end-start) for n in solo.notes())
|
||||
output=notation.measures(result['staves'][0]['measures'][notation_first:first+count],relative,'solo_'+part)
|
||||
notation.replace_region(result,notation_first,output)
|
||||
details=report.structure(solo)
|
||||
details['allocation']={'start':str(start),'end':str(end),'lead_in_measures':first-notation_first}
|
||||
solo_report=report.describe(solo,first,offset,detail=details)
|
||||
solo_model = _solo_model(str(data_dir), inputs['catalogs']['model'])
|
||||
solo = generator.solo.composer.compose(generator.solo.analysis.analyze(document), template, part, end, initial=initial, learned_model=solo_model, expressions_enabled=generator.vocabulary.role().get('expressions', True), start=start)
|
||||
composition_timings = {k: v for k, v in solo.diagnostics.items() if k.endswith('_seconds')}
|
||||
relative = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in solo.notes())
|
||||
output = generator.solo.notation.measures(result['staves'][0]['measures'][notation_first:first + count], relative, 'solo_' + part)
|
||||
generator.solo.notation.replace_region(result, notation_first, output)
|
||||
details = generator.solo.report.structure(solo)
|
||||
details['allocation'] = {'start': str(start), 'end': str(end), 'lead_in_measures': first - notation_first}
|
||||
solo_report = generator.solo.report.describe(solo, first, offset, detail=details)
|
||||
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}
|
||||
canonical = generator.comping.Canonical(document, part, seed)
|
||||
result = generator.arrange.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]
|
||||
result = generator.arrange.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,'composition_timings':composition_timings,'cache_hit':False}
|
||||
measure['id'] = role + '_' + measure['id']
|
||||
staff['tuplets'] = generator.score.reidentify(staff['measures'], staff.get('tuplets', []))
|
||||
if part != 'vocal':
|
||||
result['staves'][0]['tablature'] = True
|
||||
generator.model.validate_document(result)
|
||||
generator.fretboard.validate_document(result)
|
||||
value = {'role': role, 'document': result, 'trace': trace, 'details': details, 'report': solo_report, 'seconds': time.monotonic() - started, 'composition_timings': composition_timings, '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))
|
||||
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)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {'key': key, 'digest': generator.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']=[]
|
||||
documents = {}
|
||||
for part in generator.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']):
|
||||
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'])}]
|
||||
sounding = [copy.deepcopy(n) for d in selected for n in generator.score.measure_notes(d['staves'][0]['measures'][i]) if n['pitches'] or n.get('tuplet_ids')]
|
||||
m['voices'] = [{'id': 'voice_arranged', 'notes': _fill(sounding, generator.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
|
||||
other = d['staves'][0]['measures'][i]
|
||||
if other.get('attributes'):
|
||||
m['attributes'] = copy.deepcopy(other['attributes'])
|
||||
staff['tuplets'] = generator.arrange.pickup.retain_links(staff['measures'], staff['tuplets'])
|
||||
for span in staff['tuplets']:
|
||||
span['voice'] = 'voice_arranged'
|
||||
generator.model.validate_document(document)
|
||||
generator.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)
|
||||
|
||||
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, timings=None):
|
||||
if type(jobs) is not int or not 1<=jobs<=16:
|
||||
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 timings is not None:timings.append({'source':str(path),'role':role,'seconds':value['seconds'],'cache_hit':value['cache_hit'],'composition_timings':value.get('composition_timings',{}),'observed_reads':value.get('observed_reads',[])})
|
||||
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
|
||||
source_dir, output_dir = pathlib.Path(source_dir), pathlib.Path(output_dir)
|
||||
sources = {}
|
||||
for path in generator.parts.source_paths(source_dir):
|
||||
document = generator.parts.prepare_document(generator.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 timings is not None:
|
||||
timings.append({'source': str(path), 'role': role, 'seconds': value['seconds'], 'cache_hit': value['cache_hit'], 'composition_timings': value.get('composition_timings', {}), 'observed_reads': value.get('observed_reads', [])})
|
||||
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}
|
||||
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))
|
||||
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'])
|
||||
document['metadata']['song_id'] = generator.score.slugify(path.stem)
|
||||
generator.score.write_json(output_dir / (generator.score.slugify(path.stem) + '_' + part + '.json'), document)
|
||||
analysis_dir = output_dir.parent / 'analysis'
|
||||
analysis_dir.mkdir(parents=True, exist_ok=True)
|
||||
(analysis_dir / (generator.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','composition_timings','cache_hit','observed_reads')} 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
|
||||
stable = {role: {k: v for k, v in value.items() if k not in ('seconds', 'composition_timings', 'cache_hit', 'observed_reads')} for role, value in results[path].items()}
|
||||
(analysis_dir / (generator.score.slugify(path.stem) + '.roles')).write_text(json.dumps(stable, sort_keys=True, indent=2))
|
||||
return len(sources) * 3
|
||||
|
||||
+4
-4
@@ -9,7 +9,7 @@ import generator.engraving.validation
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.pico_artwork
|
||||
import generator.playback
|
||||
import generator.performance
|
||||
|
||||
def load_json(path: pathlib.Path) -> dict:
|
||||
document = json.loads(path.read_text(encoding='utf-8'))
|
||||
@@ -24,6 +24,9 @@ def validate_json(document: dict, path: pathlib.Path, validated_pages: set[str]
|
||||
raise ValueError('invalid score bundle; regenerate score assets')
|
||||
if (presentation.get('engine'), presentation.get('width'), presentation.get('height')) != (generator.engraving.validation.ENGINE_NAME, generator.engraving.validation.PAGE_WIDTH, generator.engraving.validation.PAGE_HEIGHT):
|
||||
raise ValueError('unsupported score presentation; regenerate score assets')
|
||||
generator.performance.validate_pages(document['music'], presentation['pages'])
|
||||
if document.get('performance') != generator.performance.timing(document['music']):
|
||||
raise ValueError('invalid performance timing')
|
||||
for page in presentation['pages']:
|
||||
if not isinstance(page, dict) or not isinstance(page.get('path'), str) or not isinstance(page.get('sha256'), str):
|
||||
raise ValueError('invalid score page manifest')
|
||||
@@ -126,7 +129,6 @@ def count_json_files(root: pathlib.Path) -> int:
|
||||
return sum(1 for p in root.rglob('*.json') if p.is_file())
|
||||
|
||||
def validate_generated_tree(root: pathlib.Path, validated_pages: set[str] | None = None) -> None:
|
||||
audio_cache = {}
|
||||
validated_pages = set() if validated_pages is None else validated_pages
|
||||
for path in root.rglob('*.json'):
|
||||
document = json.loads(path.read_text(encoding='utf-8'))
|
||||
@@ -134,5 +136,3 @@ def validate_generated_tree(root: pathlib.Path, validated_pages: set[str] | None
|
||||
generator.fretboard.validate_document(music)
|
||||
if 'presentation' in document:
|
||||
generator.pico_artwork.validate_title(root, document['presentation'].get('title_image'))
|
||||
if 'playback' in document:
|
||||
generator.playback.validate_assets(document['playback'], root, audio_cache)
|
||||
|
||||
@@ -37,8 +37,9 @@ def attacks(document: dict) -> tuple[generator.solo.types.Note, ...]:
|
||||
return tuple(sorted(result, key=lambda n: (n.start, -n.pitch.number(), n.sources)))
|
||||
|
||||
def analyze(document):
|
||||
measures=generator.score.source_staff(document)['measures']
|
||||
boundaries=[fractions.Fraction()]
|
||||
for m in measures:boundaries.append(boundaries[-1]+generator.model.fraction(m['duration']))
|
||||
tempo=next((generator.model.fraction(t['bpm'])*generator.model.written_length(t['written']) for m in measures for t in m.get('tempos',[]) if 'bpm' in t),fractions.Fraction(120))
|
||||
return Analysis(tuple(boundaries),tempo,attacks(document),boundaries[generator.score.body_start_measure_index(measures)])
|
||||
measures = generator.score.source_staff(document)['measures']
|
||||
boundaries = [fractions.Fraction()]
|
||||
for m in measures:
|
||||
boundaries.append(boundaries[-1] + generator.model.fraction(m['duration']))
|
||||
tempo = next((generator.model.fraction(t['bpm']) * generator.model.written_length(t['written']) for m in measures for t in m.get('tempos', []) if 'bpm' in t), fractions.Fraction(120))
|
||||
return Analysis(tuple(boundaries), tempo, attacks(document), boundaries[generator.score.body_start_measure_index(measures)])
|
||||
|
||||
+77
-58
@@ -8,71 +8,90 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fractions import Fraction as F
|
||||
from generator import musicxml,parts,training_io,harmonic_time
|
||||
from generator.solo import analysis,types,placement,profiles,harmonic
|
||||
import pathlib
|
||||
import fractions
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.training_io
|
||||
import generator.harmonic_time
|
||||
import generator.solo.analysis
|
||||
import generator.solo.types
|
||||
import generator.solo.placement
|
||||
import generator.solo.profiles
|
||||
import generator.solo.harmonic
|
||||
import generator.production
|
||||
|
||||
|
||||
def sounding(evidence,offset=F()):
|
||||
result=[]
|
||||
for i,event in enumerate(evidence['sounding_events']):
|
||||
p=event['pitch'];number=F(event['sounding_midi'])
|
||||
if number.denominator!=1:raise ValueError('Microtonal guitar solo is unsupported')
|
||||
pitch=types.Pitch(p['step'].lower(),F(p['alter']),p['octave']+(int(number)-int(F(event['encoded_midi'])))//12)
|
||||
result.append(types.Note(F(event['start'])-offset,F(event['end'])-offset,pitch,role='authored',event_id=f'authored_{i}'))
|
||||
def sounding(evidence, offset=fractions.Fraction()):
|
||||
result = []
|
||||
for i, event in enumerate(evidence['sounding_events']):
|
||||
p = event['pitch']
|
||||
number = fractions.Fraction(event['sounding_midi'])
|
||||
if number.denominator != 1:
|
||||
raise ValueError('Microtonal guitar solo is unsupported')
|
||||
pitch = generator.solo.types.Pitch(p['step'].lower(), fractions.Fraction(p['alter']), p['octave'] + (int(number) - int(fractions.Fraction(event['encoded_midi']))) // 12)
|
||||
result.append(generator.solo.types.Note(fractions.Fraction(event['start']) - offset, fractions.Fraction(event['end']) - offset, pitch, role='authored', event_id=f'authored_{i}'))
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def validate_evidence(document,evidence):
|
||||
def validate_evidence(document, evidence):
|
||||
"""Training uses the same source-form harmonic context as solo playback."""
|
||||
h=harmonic.Harmony(harmonic_time.solo_source(document['staves'][0]))
|
||||
checked=h.evaluate(sounding(evidence),complete=True)
|
||||
if not checked.valid:raise ValueError('Unsupported harmonic relationship in authored solo: '+checked.error)
|
||||
h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(document['staves'][0]))
|
||||
checked = h.evaluate(sounding(evidence), complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError('Unsupported harmonic relationship in authored solo: ' + checked.error)
|
||||
return checked
|
||||
|
||||
|
||||
def lookup(source,role,data_dir):
|
||||
from generator.production import SourceAnalysis
|
||||
root=Path(data_dir);manifest=root/'training/manifest.json'
|
||||
if not manifest.exists():return None
|
||||
value=json.loads(manifest.read_text())
|
||||
if value.get('version')!=1:raise ValueError('Unsupported training manifest')
|
||||
matches=[]
|
||||
def lookup(source, role, data_dir):
|
||||
root = pathlib.Path(data_dir)
|
||||
manifest = root / 'training/manifest.json'
|
||||
if not manifest.exists():
|
||||
return None
|
||||
value = json.loads(manifest.read_text())
|
||||
if value.get('version') != 1:
|
||||
raise ValueError('Unsupported training manifest')
|
||||
matches = []
|
||||
for entry in value['examples']:
|
||||
if entry['slot']!=(1 if role=='acoustic' else 2):continue
|
||||
path=(manifest.parent/entry['source']).resolve()
|
||||
if not path.is_relative_to((root/'songs').resolve()):raise ValueError('Registered sources must be in data/songs')
|
||||
doc=parts.prepare_document(musicxml.load_musicxml(path))
|
||||
if SourceAnalysis.read(doc).musical_key==source.musical_key:
|
||||
target=(manifest.parent/entry['solo']).resolve()
|
||||
if not target.is_relative_to(manifest.parent.resolve()):raise ValueError('Registered solo must be inside data/training')
|
||||
evidence=training_io.read(target,role,pitch_convention=entry['pitch_convention'])
|
||||
training_io.validate_harmony(doc,evidence,entry['id'])
|
||||
matches.append({**entry,'path':target,'sha256':hashlib.sha256(target.read_bytes()).hexdigest()})
|
||||
if len(matches)>1:raise ValueError('Multiple authored solos registered for the same source music and role')
|
||||
if entry['slot'] != (1 if role == 'acoustic' else 2):
|
||||
continue
|
||||
path = (manifest.parent / entry['source']).resolve()
|
||||
if not path.is_relative_to((root / 'songs').resolve()):
|
||||
raise ValueError('Registered sources must be in data/songs')
|
||||
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
|
||||
if generator.production.SourceAnalysis.read(doc).musical_key == source.musical_key:
|
||||
target = (manifest.parent / entry['solo']).resolve()
|
||||
if not target.is_relative_to(manifest.parent.resolve()):
|
||||
raise ValueError('Registered solo must be inside data/training')
|
||||
evidence = generator.training_io.read(target, role, pitch_convention=entry['pitch_convention'])
|
||||
generator.training_io.validate_harmony(doc, evidence, entry['id'])
|
||||
matches.append({**entry, 'path': target, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
|
||||
if len(matches) > 1:
|
||||
raise ValueError('Multiple authored solos registered for the same source music and role')
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def realize(entry,source_analysis,measures,part,start,end,initial=None):
|
||||
evidence=training_io.read(entry['path'],part,pitch_convention=entry['pitch_convention'])
|
||||
if evidence['discrepancies']:raise ValueError(entry['id']+': invalid authored timing/ties: '+str(evidence['discrepancies']))
|
||||
expected=[(a,b-a) for a,b in zip(source_analysis.boundaries,source_analysis.boundaries[1:])]
|
||||
if [(F(m['start']),F(m['duration'])) for m in evidence['measures']]!=expected:raise ValueError(entry['id']+': source/solo forms differ; register and retrain the correct pair')
|
||||
notes=sounding(evidence,source_analysis.body_offset)
|
||||
def realize(entry, source_analysis, measures, part, start, end, initial=None):
|
||||
evidence = generator.training_io.read(entry['path'], part, pitch_convention=entry['pitch_convention'])
|
||||
if evidence['discrepancies']:
|
||||
raise ValueError(entry['id'] + ': invalid authored timing/ties: ' + str(evidence['discrepancies']))
|
||||
expected = [(a, b - a) for a, b in zip(source_analysis.boundaries, source_analysis.boundaries[1:])]
|
||||
if [(fractions.Fraction(m['start']), fractions.Fraction(m['duration'])) for m in evidence['measures']] != expected:
|
||||
raise ValueError(entry['id'] + ': source/solo forms differ; register and retrain the correct pair')
|
||||
notes = sounding(evidence, source_analysis.body_offset)
|
||||
for n in notes:
|
||||
if n.start<start or n.end>end:raise ValueError(f'{entry["id"]}: authored event {n.start}–{n.end} conflicts with arrangement window {start}–{end}; no clipping applied')
|
||||
profile=profiles.for_part(part,source_analysis)
|
||||
illegal=next((n for n in notes if not placement.positions(n.pitch.number(),profile)),None)
|
||||
if illegal:raise ValueError(f'{entry["id"]}: {illegal.pitch.label()} at {illegal.start} is outside strings 1–6/frets 1–12; authored solo left unchanged')
|
||||
placed=placement.place(tuple(notes),source_analysis.tempo,profile=profile)
|
||||
if placed is None:raise ValueError(entry['id']+': no legal complete guitar placement')
|
||||
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial))
|
||||
checked=harmonic.Harmony(spans).evaluate(placed[1],complete=True)
|
||||
if not checked.valid:raise ValueError(entry['id']+': '+checked.error)
|
||||
phrase=types.Phrase('authored',start,end,placed[1],'authored','authored','handoff',theme=entry['id'],expressions=checked.contracts)
|
||||
return types.Solo(part,spans,(phrase,),source_analysis.tempo,end,
|
||||
{'construction':'authored_repertoire','authored_id':entry['id'],'authored_sha256':entry['sha256'],
|
||||
'pitch_convention':entry['pitch_convention'],'exact_sounding_events':True,
|
||||
'note':'Authored music is preserved and passes automatic contextual harmonic validation.',
|
||||
'annotations':evidence['annotations']},profile,start)
|
||||
if n.start < start or n.end > end:
|
||||
raise ValueError(f'{entry["id"]}: authored event {n.start}–{n.end} conflicts with arrangement window {start}–{end}; no clipping applied')
|
||||
profile = generator.solo.profiles.for_part(part, source_analysis)
|
||||
illegal = next((n for n in notes if not generator.solo.placement.positions(n.pitch.number(), profile)), None)
|
||||
if illegal:
|
||||
raise ValueError(f'{entry["id"]}: {illegal.pitch.label()} at {illegal.start} is outside strings 1–6/frets 1–12; authored solo left unchanged')
|
||||
placed = generator.solo.placement.place(tuple(notes), source_analysis.tempo, profile=profile)
|
||||
if placed is None:
|
||||
raise ValueError(entry['id'] + ': no legal complete guitar placement')
|
||||
spans = tuple(dataclasses.replace(s, start=s.start + start, end=s.end + start) for s in generator.harmonic_time.read({'measures': measures}, initial=initial))
|
||||
checked = generator.solo.harmonic.Harmony(spans).evaluate(placed[1], complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError(entry['id'] + ': ' + checked.error)
|
||||
phrase = generator.solo.types.Phrase('authored', start, end, placed[1], 'authored', 'authored', 'handoff', theme=entry['id'], expressions=checked.contracts)
|
||||
return generator.solo.types.Solo(part, spans, (phrase,), source_analysis.tempo, end,
|
||||
{'construction': 'authored_repertoire', 'authored_id': entry['id'], 'authored_sha256': entry['sha256'],
|
||||
'pitch_convention': entry['pitch_convention'], 'exact_sounding_events': True,
|
||||
'note': 'Authored music is preserved and passes automatic contextual harmonic validation.',
|
||||
'annotations': evidence['annotations']}, profile, start)
|
||||
|
||||
+61
-46
@@ -1,57 +1,72 @@
|
||||
"""The sole production solo composer: frozen learned model plus music validity."""
|
||||
import dataclasses
|
||||
import time
|
||||
from fractions import Fraction as F
|
||||
from pathlib import Path
|
||||
from generator import harmonic_time, vocabulary
|
||||
from generator.solo import types, profiles, harmonic, placement, learned, decoder, groups
|
||||
import fractions
|
||||
import pathlib
|
||||
import generator.harmonic_time
|
||||
import generator.vocabulary
|
||||
import generator.solo.types
|
||||
import generator.solo.profiles
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.placement
|
||||
import generator.solo.learned
|
||||
import generator.solo.decoder
|
||||
import generator.solo.groups
|
||||
|
||||
|
||||
def compose(analysis,measures,part,end,*,learned_model=None,initial=None,expressions_enabled=True,start=F()):
|
||||
began=time.monotonic()
|
||||
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial))
|
||||
profile=profiles.for_part(part,analysis);harmony=harmonic.Harmony(spans,profile)
|
||||
model=learned_model or learned.Model.load(Path(__file__).resolve().parents[2]/'data/models/solo.json')
|
||||
settings=vocabulary.role(part+'_solo')
|
||||
builder=decoder.Decoder(analysis,harmony,model,part,start,end,expressions_enabled=expressions_enabled,
|
||||
attempts=settings.get('attempts',4),seed=settings.get('choice_seed',''))
|
||||
selected=builder.compose();decoded=time.monotonic()
|
||||
placed=placement.place(selected.notes,analysis.tempo,profile=profile)
|
||||
if placed is None:raise decoder.SearchExhausted('learned solo has no complete legal guitar path')
|
||||
def compose(analysis, measures, part, end, *, learned_model=None, initial=None, expressions_enabled=True, start=fractions.Fraction()):
|
||||
began = time.monotonic()
|
||||
spans = tuple(dataclasses.replace(s, start=s.start + start, end=s.end + start) for s in generator.harmonic_time.read({'measures': measures}, initial=initial))
|
||||
profile = generator.solo.profiles.for_part(part, analysis)
|
||||
harmony = generator.solo.harmonic.Harmony(spans, profile)
|
||||
model = learned_model or generator.solo.learned.Model.load(pathlib.Path(__file__).resolve().parents[2] / 'data/models/solo.json')
|
||||
settings = generator.vocabulary.role(part + '_solo')
|
||||
builder = generator.solo.decoder.Decoder(analysis, harmony, model, part, start, end, expressions_enabled=expressions_enabled, attempts=settings.get('attempts', 4), seed=settings.get('choice_seed', ''))
|
||||
selected = builder.compose()
|
||||
decoded = time.monotonic()
|
||||
placed = generator.solo.placement.place(selected.notes, analysis.tempo, profile=profile)
|
||||
if placed is None:
|
||||
raise generator.solo.decoder.SearchExhausted('learned solo has no complete legal guitar path')
|
||||
# Phrase labels describe heard rests/arrivals. They do not impose a new
|
||||
# endpoint or reset the learned history at barlines or arbitrary windows.
|
||||
notes=placed[1];ends=[n.end for i,n in enumerate(notes[:-1]) if notes[i+1].start-n.end>=1]
|
||||
boundaries=[start,*[t for t in ends if start<t<end],end];phrases=[]
|
||||
home=tuple(n for n in notes if n.start<(ends[0] if ends else min(end,start+8)))
|
||||
for i,(a,b) in enumerate(zip(boundaries,boundaries[1:])):
|
||||
ns=tuple(n for n in notes if a<=n.start<b)
|
||||
cs=tuple(c for c in selected.contracts if a<=c.target.start<b)
|
||||
ending='handoff' if b==end else 'settle'
|
||||
phrases.append(types.Phrase(f'learned_{i+1}',a,b,ns,'learned_sequence','learned',ending,
|
||||
expressions=cs,groups=groups.describe(tuple((n.start,n.end) for n in ns)),
|
||||
theme='introduced' if i==0 else 'conditional_history',identity=home if i==0 else ()))
|
||||
result=types.Solo(part,spans,tuple(phrases),analysis.tempo,end,
|
||||
{'construction':'learned_joint_events','version':learned.VERSION,'decode_seconds':decoded-began,
|
||||
'placement_seconds':time.monotonic()-decoded,'selection_total':selected.cost,
|
||||
'objective':'seeded sampling from validated learned joint-event likelihoods; score is diagnostic, not a complete-solo ranking',
|
||||
'budget':{'attempts':builder.attempts},
|
||||
'counts':dict(builder.counts),'frontiers':builder.trace,'completed_candidates':[
|
||||
{'rank':i+1,'cost':s.cost,'events':[(str(a),str(b),p) for a,b,p in s.events]} for i,s in enumerate(builder.completed)],
|
||||
'expressions_enabled':expressions_enabled},profile,start)
|
||||
validate(result);return result
|
||||
|
||||
notes = placed[1]
|
||||
ends = [n.end for i, n in enumerate(notes[:-1]) if notes[i + 1].start - n.end >= 1]
|
||||
boundaries = [start, *[t for t in ends if start < t < end], end]
|
||||
phrases = []
|
||||
home = tuple(n for n in notes if n.start < (ends[0] if ends else min(end, start + 8)))
|
||||
for i, (a, b) in enumerate(zip(boundaries, boundaries[1:])):
|
||||
ns = tuple(n for n in notes if a <= n.start < b)
|
||||
cs = tuple(c for c in selected.contracts if a <= c.target.start < b)
|
||||
ending = 'handoff' if b == end else 'settle'
|
||||
phrases.append(generator.solo.types.Phrase(f'learned_{i + 1}', a, b, ns, 'learned_sequence', 'learned', ending,
|
||||
expressions=cs, groups=generator.solo.groups.describe(tuple((n.start, n.end) for n in ns)),
|
||||
theme='introduced' if i == 0 else 'conditional_history', identity=home if i == 0 else ()))
|
||||
result = generator.solo.types.Solo(part, spans, tuple(phrases), analysis.tempo, end,
|
||||
{'construction': 'learned_joint_events', 'version': generator.solo.learned.VERSION, 'decode_seconds': decoded - began,
|
||||
'placement_seconds': time.monotonic() - decoded, 'selection_total': selected.cost,
|
||||
'objective': 'seeded sampling from validated learned joint-event likelihoods; score is diagnostic, not a complete-solo ranking',
|
||||
'budget': {'attempts': builder.attempts},
|
||||
'counts': dict(builder.counts), 'frontiers': builder.trace, 'completed_candidates': [
|
||||
{'rank': i + 1, 'cost': s.cost, 'events': [(str(a), str(b), p) for a, b, p in s.events]} for i, s in enumerate(builder.completed)],
|
||||
'expressions_enabled': expressions_enabled}, profile, start)
|
||||
validate(result)
|
||||
return result
|
||||
|
||||
def validate(solo):
|
||||
previous=None
|
||||
previous = None
|
||||
for n in solo.notes():
|
||||
if n.start<solo.start or n.end>solo.end or n.end<=n.start or previous and previous.end>n.start:raise ValueError('overlapping/out-of-bounds sounding event')
|
||||
if n.position not in placement.positions(n.pitch.number(),solo.profile):raise ValueError('invalid guitar placement')
|
||||
previous=n
|
||||
checked=harmonic.Harmony(solo.harmony).evaluate(solo.notes(),until=solo.end,complete=True,
|
||||
if n.start < solo.start or n.end > solo.end or n.end <= n.start or previous and previous.end > n.start:
|
||||
raise ValueError('overlapping/out-of-bounds sounding event')
|
||||
if n.position not in generator.solo.placement.positions(n.pitch.number(), solo.profile):
|
||||
raise ValueError('invalid guitar placement')
|
||||
previous = n
|
||||
checked = generator.solo.harmonic.Harmony(solo.harmony).evaluate(solo.notes(), until=solo.end, complete=True,
|
||||
expressions=tuple(c for p in solo.phrases for c in p.expressions))
|
||||
if not checked.valid:raise ValueError(checked.error)
|
||||
cursor=solo.start
|
||||
if not checked.valid:
|
||||
raise ValueError(checked.error)
|
||||
cursor = solo.start
|
||||
for p in solo.phrases:
|
||||
if p.start!=cursor or p.end<=p.start:raise ValueError('phrase coverage gap or overlap')
|
||||
cursor=p.end
|
||||
if cursor!=solo.end:raise ValueError('incomplete solo duration')
|
||||
if p.start != cursor or p.end <= p.start:
|
||||
raise ValueError('phrase coverage gap or overlap')
|
||||
cursor = p.end
|
||||
if cursor != solo.end:
|
||||
raise ValueError('incomplete solo duration')
|
||||
|
||||
+319
-252
@@ -11,297 +11,364 @@ import dataclasses
|
||||
import hashlib
|
||||
import random
|
||||
import math
|
||||
from fractions import Fraction as F
|
||||
from generator import model as music, vocabulary
|
||||
from generator.solo import expressions, groups, learned, types
|
||||
|
||||
|
||||
class SearchExhausted(ValueError):pass
|
||||
import fractions
|
||||
import generator.model
|
||||
import generator.vocabulary
|
||||
import generator.solo.expressions
|
||||
import generator.solo.groups
|
||||
import generator.solo.learned
|
||||
import generator.solo.types
|
||||
import generator.harmony
|
||||
|
||||
class SearchExhausted(ValueError):
|
||||
pass
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class State:
|
||||
events:tuple=()
|
||||
notes:tuple=()
|
||||
contracts:tuple=()
|
||||
history:tuple=()
|
||||
previous:int|None=None
|
||||
cost:float=0.
|
||||
|
||||
|
||||
def event_key(events):return tuple((a,b,-1 if p is None else p) for a,b,p in events)
|
||||
events: tuple = ()
|
||||
notes: tuple = ()
|
||||
contracts: tuple = ()
|
||||
history: tuple = ()
|
||||
previous: int | None = None
|
||||
cost: float = 0.
|
||||
|
||||
def event_key(events): return tuple((a, b, -1 if p is None else p) for a, b, p in events)
|
||||
|
||||
class Decoder:
|
||||
def __init__(self,analysis,harmony,model,role,start,end,*,expressions_enabled=True,attempts=4,seed=''):
|
||||
if type(attempts) is not int or not 1<=attempts<=16:raise ValueError('attempts must be between 1 and 16')
|
||||
self.analysis,self.harmony,self.model,self.role=analysis,harmony,model,role
|
||||
self.start,self.end=start,end
|
||||
self.attempts=attempts
|
||||
source=tuple((str(n.start),str(n.end),n.pitch.number()) for n in analysis.melody)
|
||||
self.seed=seed or hashlib.sha256(repr((source,role,learned.VERSION)).encode()).hexdigest()
|
||||
self.expressions_enabled=expressions_enabled
|
||||
self.low,self.high=harmony.profile.register
|
||||
limits=vocabulary.data('roles')['difficulty']
|
||||
self.durations=tuple(d for d in model.durations if 0<d<=4 and d.denominator in (1,2,4) and
|
||||
(d.denominator!=4 or analysis.tempo<=limits['sixteenth_max_bpm']))
|
||||
self.allow_triplets=analysis.tempo<=limits['triplet_max_bpm']
|
||||
self._actions={};self._context={};self._pitches={};self._colors={};self._permissions={};self.trace=[];self.counts=collections.Counter()
|
||||
|
||||
def context(self,time):
|
||||
def __init__(self, analysis, harmony, model, role, start, end, *, expressions_enabled=True, attempts=4, seed=''):
|
||||
if type(attempts) is not int or not 1 <= attempts <= 16:
|
||||
raise ValueError('attempts must be between 1 and 16')
|
||||
self.analysis, self.harmony, self.model, self.role = analysis, harmony, model, role
|
||||
self.start, self.end = start, end
|
||||
self.attempts = attempts
|
||||
source = tuple((str(n.start), str(n.end), n.pitch.number()) for n in analysis.melody)
|
||||
self.seed = seed or hashlib.sha256(repr((source, role, generator.solo.learned.VERSION)).encode()).hexdigest()
|
||||
self.expressions_enabled = expressions_enabled
|
||||
self.low, self.high = harmony.profile.register
|
||||
limits = generator.vocabulary.data('roles')['difficulty']
|
||||
self.durations = tuple(d for d in model.durations if 0 < d <= 4 and d.denominator in (1, 2, 4) and
|
||||
(d.denominator != 4 or analysis.tempo <= limits['sixteenth_max_bpm']))
|
||||
self.allow_triplets = analysis.tempo <= limits['triplet_max_bpm']
|
||||
self._actions = {}
|
||||
self._context = {}
|
||||
self._pitches = {}
|
||||
self._colors = {}
|
||||
self._permissions = {}
|
||||
self.trace = []
|
||||
self.counts = collections.Counter()
|
||||
def context(self, time):
|
||||
if time not in self._context:
|
||||
harmonic,root,future=learned.chord_context(self.harmony,time)
|
||||
self._context[time]=(harmonic,root,learned.phase_at(self.analysis,time),learned.source_feature(self.analysis,time,root),future)
|
||||
harmonic, root, future = generator.solo.learned.chord_context(self.harmony, time)
|
||||
self._context[time] = (harmonic, root, generator.solo.learned.phase_at(self.analysis, time), generator.solo.learned.source_feature(self.analysis, time, root), future)
|
||||
return self._context[time]
|
||||
|
||||
def pitch(self,number,time):
|
||||
span=self.harmony.at(time)
|
||||
identity=(number,span.start if span else None)
|
||||
if identity not in self._pitches:self._pitches[identity]=self._spell(number,time)
|
||||
def pitch(self, number, time):
|
||||
span = self.harmony.at(time)
|
||||
identity = (number, span.start if span else None)
|
||||
if identity not in self._pitches:
|
||||
self._pitches[identity] = self._spell(number, time)
|
||||
return self._pitches[identity]
|
||||
|
||||
def _spell(self,number,time):
|
||||
span=self.harmony.at(time)
|
||||
if span and span.chord!='N.C.':
|
||||
chord=expressions.parsed_chord(span.chord)
|
||||
from generator import harmony as chords
|
||||
for degree,alter in chords.degrees(chord).items():
|
||||
p=expressions.degree_pitch(chord,degree,alter,number)
|
||||
if p.number()==number:return p
|
||||
for letter,alter in zip(music.LETTERS,span.key['alterations']):
|
||||
accidental=music.fraction(alter)
|
||||
if (music.NATURAL[letter]+accidental)%12==number%12:
|
||||
return types.Pitch(letter,accidental,int((number-music.NATURAL[letter]-accidental)//12)-1)
|
||||
names=(('C',0),('D',-1),('D',0),('E',-1),('E',0),('F',0),('F',1),('G',0),('A',-1),('A',0),('B',-1),('B',0))
|
||||
letter,alter=names[number%12]
|
||||
return types.Pitch(letter.lower(),F(alter),number//12-1)
|
||||
|
||||
def note(self,a,b,p,role='learned'):
|
||||
pitch=p if isinstance(p,types.Pitch) else self.pitch(p,a)
|
||||
return types.Note(a,b,pitch,role=role,event_id=f'{a}:{b}:{pitch.number()}')
|
||||
|
||||
def color_bases(self,n):
|
||||
key=(n.start,n.end,n.pitch.number())
|
||||
if key in self._colors:return self._colors[key]
|
||||
bases=[]
|
||||
regions=[(None,n)]
|
||||
regions.extend((s.start,dataclasses.replace(n,start=s.start)) for s in self.harmony.spans
|
||||
if n.start<s.start<n.end and s.start-n.start<=F(vocabulary.data('harmony')['anticipation_max']))
|
||||
for boundary,arrived in regions:
|
||||
exposed=[s for s,_,_ in self.harmony.segments(arrived) if n.pitch.number()%12 not in s.roles.basic|s.roles.specified]
|
||||
if not exposed:continue
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if arrived.end-arrived.start>F(rule['max_beats']):continue
|
||||
if any(s.chord=='N.C.' or not expressions.eligible_color(rule,s,self.harmony) or
|
||||
expressions.degree_pitch(expressions.parsed_chord(s.chord),rule['degree'],rule['alter'],n.pitch.number()).number()!=n.pitch.number()
|
||||
for s in exposed):continue
|
||||
bases.append((rule,boundary))
|
||||
self._colors[key]=tuple(bases)
|
||||
def _spell(self, number, time):
|
||||
span = self.harmony.at(time)
|
||||
if span and span.chord != 'N.C.':
|
||||
chord = generator.solo.expressions.parsed_chord(span.chord)
|
||||
for degree, alter in generator.harmony.degrees(chord).items():
|
||||
p = generator.solo.expressions.degree_pitch(chord, degree, alter, number)
|
||||
if p.number() == number:
|
||||
return p
|
||||
for letter, alter in zip(generator.model.LETTERS, span.key['alterations']):
|
||||
accidental = generator.model.fraction(alter)
|
||||
if (generator.model.NATURAL[letter] + accidental) % 12 == number % 12:
|
||||
return generator.solo.types.Pitch(letter, accidental, int((number - generator.model.NATURAL[letter] - accidental) // 12) - 1)
|
||||
names = (('C', 0), ('D', -1), ('D', 0), ('E', -1), ('E', 0), ('F', 0), ('F', 1), ('G', 0), ('A', -1), ('A', 0), ('B', -1), ('B', 0))
|
||||
letter, alter = names[number % 12]
|
||||
return generator.solo.types.Pitch(letter.lower(), fractions.Fraction(alter), number // 12 - 1)
|
||||
def note(self, a, b, p, role='learned'):
|
||||
pitch = p if isinstance(p, generator.solo.types.Pitch) else self.pitch(p, a)
|
||||
return generator.solo.types.Note(a, b, pitch, role=role, event_id=f'{a}:{b}:{pitch.number()}')
|
||||
def color_bases(self, n):
|
||||
key = (n.start, n.end, n.pitch.number())
|
||||
if key in self._colors:
|
||||
return self._colors[key]
|
||||
bases = []
|
||||
regions = [(None, n)]
|
||||
regions.extend((s.start, dataclasses.replace(n, start=s.start)) for s in self.harmony.spans
|
||||
if n.start < s.start < n.end and s.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']))
|
||||
for boundary, arrived in regions:
|
||||
exposed = [s for s, _, _ in self.harmony.segments(arrived) if n.pitch.number() % 12 not in s.roles.basic | s.roles.specified]
|
||||
if not exposed:
|
||||
continue
|
||||
for rule in generator.vocabulary.data('harmony')['colors']:
|
||||
if arrived.end - arrived.start > fractions.Fraction(rule['max_beats']):
|
||||
continue
|
||||
if any(s.chord == 'N.C.' or not generator.solo.expressions.eligible_color(rule, s, self.harmony) or
|
||||
generator.solo.expressions.degree_pitch(generator.solo.expressions.parsed_chord(s.chord), rule['degree'], rule['alter'], n.pitch.number()).number() != n.pitch.number()
|
||||
for s in exposed):
|
||||
continue
|
||||
bases.append((rule, boundary))
|
||||
self._colors[key] = tuple(bases)
|
||||
return self._colors[key]
|
||||
|
||||
def color_contract(self,n,target,rule,boundary):
|
||||
span=self.harmony.at(boundary if boundary is not None else n.start)
|
||||
if not span:return None
|
||||
kind='anticipated_color' if boundary is not None else 'color'
|
||||
return types.ExpressionContract(n.event_id+':'+rule['id']+':'+str(boundary),kind,rule['id'],(n,),target,span.chord,boundary)
|
||||
|
||||
def permission(self,n):
|
||||
key=(n.start,n.end,n.pitch.number())
|
||||
if key not in self._permissions:self._permissions[key]=self._permission(n)
|
||||
def color_contract(self, n, target, rule, boundary):
|
||||
span = self.harmony.at(boundary if boundary is not None else n.start)
|
||||
if not span:
|
||||
return None
|
||||
kind = 'anticipated_color' if boundary is not None else 'color'
|
||||
return generator.solo.types.ExpressionContract(n.event_id + ':' + rule['id'] + ':' + str(boundary), kind, rule['id'], (n,), target, span.chord, boundary)
|
||||
def permission(self, n):
|
||||
key = (n.start, n.end, n.pitch.number())
|
||||
if key not in self._permissions:
|
||||
self._permissions[key] = self._permission(n)
|
||||
return self._permissions[key]
|
||||
|
||||
def _permission(self,n):
|
||||
def _permission(self, n):
|
||||
"""Find justified color/anticipation support, never authorize by label."""
|
||||
# Source spans are contiguous. These are precisely the ordinary-event
|
||||
# branches of Harmony.evaluate, without rebuilding an empty contract map.
|
||||
if self.harmony.at(n.start) and n.end<=self.harmony.spans[-1].end:
|
||||
if n.pitch.number()%12 in self.harmony.members(n.start,n.end):return ()
|
||||
if n.end-n.start<2 and n.pitch.number()%12 in self.harmony.permitted(n.start,n.end):return ()
|
||||
if not self.expressions_enabled:return None
|
||||
span=self.harmony.at(n.start)
|
||||
if not span or span.chord=='N.C.':return None
|
||||
for rule,boundary in self.color_bases(n):
|
||||
if rule.get('resolve'):continue
|
||||
c=self.color_contract(n,n,rule,boundary)
|
||||
if self.harmony.evaluate((n,),complete=True,expressions=(c,),infer=False).valid:return (c,)
|
||||
for arriving in self.harmony.spans:
|
||||
if n.start<arriving.start<n.end and arriving.start-n.start<=F(vocabulary.data('harmony')['anticipation_max']):
|
||||
c=types.ExpressionContract(n.event_id+':anticipation','anticipation','early_chord_tone',(n,),n,arriving.chord,arriving.start)
|
||||
if self.harmony.evaluate((n,),complete=True,expressions=(c,),infer=False).valid:return (c,)
|
||||
return None
|
||||
|
||||
def tails(self,n,target):
|
||||
"""Finite resolution timing: direct, real space, or one valid intervening note."""
|
||||
gap=target.start-n.end
|
||||
if gap<0:return
|
||||
if gap==0:
|
||||
yield ((n.start,n.end,n.pitch.number()),(target.start,target.end,target.pitch.number())),(n,target)
|
||||
return
|
||||
yield ((n.start,n.end,n.pitch.number()),(n.end,target.start,None),(target.start,target.end,target.pitch.number())),(n,target)
|
||||
windows={(n.end,target.start)} if gap<=1 else set()
|
||||
if gap>=1:windows|={(n.end,n.end+F(1,2)),(target.start-F(1,2),target.start)}
|
||||
for a,b in sorted(windows):
|
||||
for number in range(max(self.low,n.pitch.number()-3),min(self.high,n.pitch.number()+3)+1):
|
||||
mid=self.note(a,b,number)
|
||||
if number in (n.pitch.number(),target.pitch.number()) or not self.harmony.ordinary(mid):continue
|
||||
events=[(n.start,n.end,n.pitch.number())]
|
||||
if a>n.end:events.append((n.end,a,None))
|
||||
events.append((a,b,number))
|
||||
if b<target.start:events.append((b,target.start,None))
|
||||
events.append((target.start,target.end,target.pitch.number()))
|
||||
yield tuple(events),(n,mid,target)
|
||||
|
||||
def resolutions(self,n):
|
||||
"""Joint color/delayed-approach actions; the destination is never optional."""
|
||||
requests=[]
|
||||
for rule,boundary in self.color_bases(n):
|
||||
if self.harmony.at(n.start) and n.end <= self.harmony.spans[-1].end:
|
||||
if n.pitch.number() % 12 in self.harmony.members(n.start, n.end):
|
||||
return ()
|
||||
if n.end - n.start < 2 and n.pitch.number() % 12 in self.harmony.permitted(n.start, n.end):
|
||||
return ()
|
||||
if not self.expressions_enabled:
|
||||
return None
|
||||
span = self.harmony.at(n.start)
|
||||
if not span or span.chord == 'N.C.':
|
||||
return None
|
||||
for rule, boundary in self.color_bases(n):
|
||||
if rule.get('resolve'):
|
||||
for gap in (F(),F(1,2),F(1)):
|
||||
if gap<=F(vocabulary.data('harmony')['resolution_gap']):requests.append(('color',rule,boundary,n.end+gap,(-2,-1,1,2)))
|
||||
delay=vocabulary.data('lead_ins')['delayed_approach']
|
||||
if n.end-n.start<=F(delay['max_note']) and n.pitch.number()%12 not in self.harmony.permitted(n.start,n.end):
|
||||
for arrival in map(F,delay['arrivals']):
|
||||
if n.end<=n.start+arrival<=n.start+F(delay['deadline']):requests.append(('delayed',delay,None,n.start+arrival,delay['steps']))
|
||||
for kind,rule,boundary,arrival,steps in requests:
|
||||
continue
|
||||
c = self.color_contract(n, n, rule, boundary)
|
||||
if self.harmony.evaluate((n,), complete=True, expressions=(c,), infer=False).valid:
|
||||
return tuple([c])
|
||||
for arriving in self.harmony.spans:
|
||||
if n.start < arriving.start < n.end and arriving.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
|
||||
c = generator.solo.types.ExpressionContract(n.event_id + ':anticipation', 'anticipation', 'early_chord_tone', (n,), n, arriving.chord, arriving.start)
|
||||
if self.harmony.evaluate((n,), complete=True, expressions=(c,), infer=False).valid:
|
||||
return tuple([c])
|
||||
return None
|
||||
def tails(self, n, target):
|
||||
"""Finite resolution timing: direct, real space, or one valid intervening note."""
|
||||
gap = target.start - n.end
|
||||
if gap < 0:
|
||||
return
|
||||
if gap == 0:
|
||||
yield ((n.start, n.end, n.pitch.number()), (target.start, target.end, target.pitch.number())), (n, target)
|
||||
return
|
||||
yield ((n.start, n.end, n.pitch.number()), (n.end, target.start, None), (target.start, target.end, target.pitch.number())), (n, target)
|
||||
windows = {(n.end, target.start)} if gap <= 1 else set()
|
||||
if gap >= 1:
|
||||
windows |= {(n.end, n.end + fractions.Fraction(1, 2)), (target.start - fractions.Fraction(1, 2), target.start)}
|
||||
for a, b in sorted(windows):
|
||||
for number in range(max(self.low, n.pitch.number() - 3), min(self.high, n.pitch.number() + 3) + 1):
|
||||
mid = self.note(a, b, number)
|
||||
if number in (n.pitch.number(), target.pitch.number()) or not self.harmony.ordinary(mid):
|
||||
continue
|
||||
events = [(n.start, n.end, n.pitch.number())]
|
||||
if a > n.end:
|
||||
events.append((n.end, a, None))
|
||||
events.append((a, b, number))
|
||||
if b < target.start:
|
||||
events.append((b, target.start, None))
|
||||
events.append((target.start, target.end, target.pitch.number()))
|
||||
yield tuple(events), (n, mid, target)
|
||||
def resolutions(self, n):
|
||||
"""Joint color/delayed-approach actions; the destination is never optional."""
|
||||
requests = []
|
||||
for rule, boundary in self.color_bases(n):
|
||||
if rule.get('resolve'):
|
||||
for gap in (fractions.Fraction(), fractions.Fraction(1, 2), fractions.Fraction(1)):
|
||||
if gap <= fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap']):
|
||||
requests.append(('color', rule, boundary, n.end + gap, (-2, -1, 1, 2)))
|
||||
delay = generator.vocabulary.data('lead_ins')['delayed_approach']
|
||||
if n.end - n.start <= fractions.Fraction(delay['max_note']) and n.pitch.number() % 12 not in self.harmony.permitted(n.start, n.end):
|
||||
for arrival in map(fractions.Fraction, delay['arrivals']):
|
||||
if n.end <= n.start + arrival <= n.start + fractions.Fraction(delay['deadline']):
|
||||
requests.append(('delayed', delay, None, n.start + arrival, delay['steps']))
|
||||
for kind, rule, boundary, arrival, steps in requests:
|
||||
for delta in steps:
|
||||
number=n.pitch.number()+delta
|
||||
if not self.low<=number<=self.high:continue
|
||||
for hold in (F(1,2),F(1),F(2)):
|
||||
if arrival+hold>self.end or number%12 not in self.harmony.members(arrival,arrival+hold):continue
|
||||
target=self.note(arrival,arrival+hold,number,'destination')
|
||||
c=self.color_contract(n,target,rule,boundary) if kind=='color' else types.ExpressionContract(n.event_id+':delayed','delayed_approach',rule['id'],(n,),target,self.harmony.at(arrival).chord)
|
||||
number = n.pitch.number() + delta
|
||||
if not self.low <= number <= self.high:
|
||||
continue
|
||||
for hold in (fractions.Fraction(1, 2), fractions.Fraction(1), fractions.Fraction(2)):
|
||||
if arrival + hold > self.end or number % 12 not in self.harmony.members(arrival, arrival + hold):
|
||||
continue
|
||||
target = self.note(arrival, arrival + hold, number, 'destination')
|
||||
c = self.color_contract(n, target, rule, boundary) if kind == 'color' else generator.solo.types.ExpressionContract(n.event_id + ':delayed', 'delayed_approach', rule['id'], (n,), target, self.harmony.at(arrival).chord)
|
||||
# Color and delayed-approach contracts permit this intervening
|
||||
# window. Validate the invariant endpoints once; tails() checks
|
||||
# every inserted note's ordinary full-duration permission itself.
|
||||
if not self.harmony.evaluate((n,target),complete=True,expressions=(c,),infer=False).valid:continue
|
||||
for events,ns in self.tails(n,target):
|
||||
if not groups.valid(tuple((a,b) for a,b,_ in events)):continue
|
||||
yield events,ns,(c,)
|
||||
|
||||
def actions(self,time):
|
||||
if time in self._actions:return self._actions[time]
|
||||
result=[]
|
||||
def add(events,notes,contracts=()):result.append((events,notes,contracts))
|
||||
if not self.harmony.evaluate((n, target), complete=True, expressions=(c,), infer=False).valid:
|
||||
continue
|
||||
for events, ns in self.tails(n, target):
|
||||
if not generator.solo.groups.valid(tuple((a, b) for a, b, _ in events)):
|
||||
continue
|
||||
yield events, ns, (c,)
|
||||
def actions(self, time):
|
||||
if time in self._actions:
|
||||
return self._actions[time]
|
||||
result = []
|
||||
def add(events, notes, contracts=()): result.append((events, notes, contracts))
|
||||
for duration in self.durations:
|
||||
end=time+duration
|
||||
if end>self.end:continue
|
||||
add(((time,end,None),),())
|
||||
for number in range(self.low,self.high+1):
|
||||
n=self.note(time,end,number);support=self.permission(n)
|
||||
if support is not None:add(((time,end,number),),(n,),support)
|
||||
end = time + duration
|
||||
if end > self.end:
|
||||
continue
|
||||
add(((time, end, None),), ())
|
||||
for number in range(self.low, self.high + 1):
|
||||
n = self.note(time, end, number)
|
||||
support = self.permission(n)
|
||||
if support is not None:
|
||||
add(((time, end, number),), (n,), support)
|
||||
if self.expressions_enabled and not self.harmony.ordinary(n):
|
||||
for events,ns,contracts in self.resolutions(n):add(events,ns,contracts)
|
||||
rule=vocabulary.data('lead_ins')['passing']
|
||||
if duration<=F(rule['max_note']):
|
||||
for events, ns, contracts in self.resolutions(n):
|
||||
add(events, ns, contracts)
|
||||
rule = generator.vocabulary.data('lead_ins')['passing']
|
||||
if duration <= fractions.Fraction(rule['max_note']):
|
||||
for step in rule['steps']:
|
||||
p=number+step
|
||||
if not self.low<=p<=self.high:continue
|
||||
for hold in (F(1,2),F(1),F(2)):
|
||||
if end+hold>self.end:continue
|
||||
target=self.note(end,end+hold,p);extra=self.permission(target)
|
||||
if extra is None or any(c.kind not in ('color','anticipated_color') for c in extra):continue
|
||||
c=types.ExpressionContract(n.event_id+':passing','passing',rule['id'],(n,),target,self.harmony.at(end).chord)
|
||||
add(((time,end,number),(end,end+hold,p)),(n,target),(c,*extra))
|
||||
p = number + step
|
||||
if not self.low <= p <= self.high:
|
||||
continue
|
||||
for hold in (fractions.Fraction(1, 2), fractions.Fraction(1), fractions.Fraction(2)):
|
||||
if end + hold > self.end:
|
||||
continue
|
||||
target = self.note(end, end + hold, p)
|
||||
extra = self.permission(target)
|
||||
if extra is None or any(c.kind not in ('color', 'anticipated_color') for c in extra):
|
||||
continue
|
||||
c = generator.solo.types.ExpressionContract(n.event_id + ':passing', 'passing', rule['id'], (n,), target, self.harmony.at(end).chord)
|
||||
add(((time, end, number), (end, end + hold, p)), (n, target), (c, *extra))
|
||||
if self.expressions_enabled:
|
||||
limits=vocabulary.data('roles')['difficulty']
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
difficulty=rule.get('difficulty')
|
||||
if difficulty and self.analysis.tempo>limits[difficulty+'_max_bpm']:continue
|
||||
limits = generator.vocabulary.data('roles')['difficulty']
|
||||
for rule in generator.vocabulary.data('lead_ins')['patterns']:
|
||||
difficulty = rule.get('difficulty')
|
||||
if difficulty and self.analysis.tempo > limits[difficulty + '_max_bpm']:
|
||||
continue
|
||||
# Connections require a concrete preceding note, checked at expansion.
|
||||
durations=tuple(map(F,rule['durations']));gaps=tuple(map(F,rule.get('internal_gaps',['0']*(len(durations)-1))))
|
||||
target_time=time+sum(durations,F())+sum(gaps,F())+F(rule['gap'])
|
||||
span=self.harmony.at(target_time)
|
||||
if not span or span.chord=='N.C.':continue
|
||||
holds=(F(1,3),) if difficulty=='triplet' else (F(1,2),F(1),F(2))
|
||||
durations = tuple(map(fractions.Fraction, rule['durations']))
|
||||
gaps = tuple(map(fractions.Fraction, rule.get('internal_gaps', ['0'] * (len(durations) - 1))))
|
||||
target_time = time + sum(durations, fractions.Fraction()) + sum(gaps, fractions.Fraction()) + fractions.Fraction(rule['gap'])
|
||||
span = self.harmony.at(target_time)
|
||||
if not span or span.chord == 'N.C.':
|
||||
continue
|
||||
holds = (fractions.Fraction(1, 3),) if difficulty == 'triplet' else (fractions.Fraction(1, 2), fractions.Fraction(1), fractions.Fraction(2))
|
||||
for duration in holds:
|
||||
end=target_time+duration
|
||||
if end>self.end:continue
|
||||
for number in range(self.low,self.high+1):
|
||||
if number%12 not in self.harmony.members(target_time,end):continue
|
||||
target=self.note(target_time,end,number,'destination');cursor=time;notes=[];events=[]
|
||||
for i,(delta,d) in enumerate(zip(rule['semitones'],durations)):
|
||||
n=self.note(cursor,cursor+d,expressions.chromatic(target.pitch,delta),rule['kind'])
|
||||
notes.append(n);events.append((n.start,n.end,n.pitch.number()));cursor=n.end
|
||||
gap=gaps[i] if i<len(gaps) else F(rule['gap'])
|
||||
if gap:events.append((cursor,cursor+gap,None));cursor+=gap
|
||||
if any(not self.low<=n.pitch.number()<=self.high for n in notes):continue
|
||||
if not groups.valid(tuple((a,b) for a,b,p in (*events,(target_time,end,number)))):continue
|
||||
contract=types.ExpressionContract(f'{time}:{number}:{rule["id"]}',rule['kind'],rule['id'],tuple(notes),target,span.chord)
|
||||
ns=(*notes,target)
|
||||
if rule['kind']=='connection':
|
||||
end = target_time + duration
|
||||
if end > self.end:
|
||||
continue
|
||||
for number in range(self.low, self.high + 1):
|
||||
if number % 12 not in self.harmony.members(target_time, end):
|
||||
continue
|
||||
target = self.note(target_time, end, number, 'destination')
|
||||
cursor = time
|
||||
notes = []
|
||||
events = []
|
||||
for i, (delta, d) in enumerate(zip(rule['semitones'], durations)):
|
||||
n = self.note(cursor, cursor + d, generator.solo.expressions.chromatic(target.pitch, delta), rule['kind'])
|
||||
notes.append(n)
|
||||
events.append((n.start, n.end, n.pitch.number()))
|
||||
cursor = n.end
|
||||
gap = gaps[i] if i < len(gaps) else fractions.Fraction(rule['gap'])
|
||||
if gap:
|
||||
events.append((cursor, cursor + gap, None))
|
||||
cursor += gap
|
||||
if any(not self.low <= n.pitch.number() <= self.high for n in notes):
|
||||
continue
|
||||
if not generator.solo.groups.valid(tuple((a, b) for a, b, p in (*events, (target_time, end, number)))):
|
||||
continue
|
||||
contract = generator.solo.types.ExpressionContract(f'{time}:{number}:{rule["id"]}', rule['kind'], rule['id'], tuple(notes), target, span.chord)
|
||||
ns = (*notes, target)
|
||||
if rule['kind'] == 'connection':
|
||||
# Validation waits for the actual incoming event.
|
||||
add((*events,(target_time,end,number)),ns,(contract,))
|
||||
elif self.harmony.evaluate(ns,complete=True,expressions=(contract,),infer=False).valid:
|
||||
add((*events,(target_time,end,number)),ns,(contract,))
|
||||
add((*events, (target_time, end, number)), ns, (contract,))
|
||||
elif self.harmony.evaluate(ns, complete=True, expressions=(contract,), infer=False).valid:
|
||||
add((*events, (target_time, end, number)), ns, (contract,))
|
||||
# Complete ordinary triplet groups; do not leave a third-beat residual.
|
||||
if self.allow_triplets and time+1<=self.end:
|
||||
for number in range(self.low,self.high+1):
|
||||
for direction in (-1,0,1):
|
||||
numbers=[number];cursor=time
|
||||
if self.allow_triplets and time + 1 <= self.end:
|
||||
for number in range(self.low, self.high + 1):
|
||||
for direction in (-1, 0, 1):
|
||||
numbers = [number]
|
||||
cursor = time
|
||||
for _ in range(2):
|
||||
nxt=next((p for p in range(numbers[-1]+direction,numbers[-1]+direction*4,direction or 1)
|
||||
if self.low<=p<=self.high and p%12 in self.harmony.permitted(cursor+F(1,3),cursor+F(2,3))),None) if direction else number
|
||||
if nxt is None:break
|
||||
numbers.append(nxt);cursor+=F(1,3)
|
||||
if len(numbers)!=3:continue
|
||||
ns=tuple(self.note(time+i*F(1,3),time+(i+1)*F(1,3),p) for i,p in enumerate(numbers))
|
||||
if self.harmony.evaluate(ns,complete=True,infer=False).valid:add(tuple((n.start,n.end,n.pitch.number()) for n in ns),ns)
|
||||
self._actions[time]=tuple(result);self.counts['valid_structures']+=len(result)
|
||||
nxt = next((p for p in range(numbers[-1] + direction, numbers[-1] + direction * 4, direction or 1)
|
||||
if self.low <= p <= self.high and p % 12 in self.harmony.permitted(cursor + fractions.Fraction(1, 3), cursor + fractions.Fraction(2, 3))), None) if direction else number
|
||||
if nxt is None:
|
||||
break
|
||||
numbers.append(nxt)
|
||||
cursor += fractions.Fraction(1, 3)
|
||||
if len(numbers) != 3:
|
||||
continue
|
||||
ns = tuple(self.note(time + i * fractions.Fraction(1, 3), time + (i + 1) * fractions.Fraction(1, 3), p) for i, p in enumerate(numbers))
|
||||
if self.harmony.evaluate(ns, complete=True, infer=False).valid:
|
||||
add(tuple((n.start, n.end, n.pitch.number()) for n in ns), ns)
|
||||
self._actions[time] = tuple(result)
|
||||
self.counts['valid_structures'] += len(result)
|
||||
return self._actions[time]
|
||||
|
||||
def extend(self,state,action):
|
||||
events,notes,contracts=action
|
||||
def extend(self, state, action):
|
||||
events, notes, contracts = action
|
||||
# Training tokenization merges contiguous silence. Never manufacture a
|
||||
# rest-to-rest transition absent from that event representation.
|
||||
if state.events and state.events[-1][2] is None and events[0][2] is None:return None
|
||||
if notes and state.previous is not None and abs(notes[0].pitch.number()-state.previous)>12:return None
|
||||
if any(c.kind in ('connection','passing') and c.preparation is None for c in contracts):
|
||||
if not state.notes:return None
|
||||
contracts=tuple(dataclasses.replace(c,preparation=state.notes[-1]) if c.kind in ('connection','passing') and c.preparation is None else c for c in contracts)
|
||||
if state.events and state.events[-1][2] is None and events[0][2] is None:
|
||||
return None
|
||||
if notes and state.previous is not None and abs(notes[0].pitch.number() - state.previous) > 12:
|
||||
return None
|
||||
if any(c.kind in ('connection', 'passing') and c.preparation is None for c in contracts):
|
||||
if not state.notes:
|
||||
return None
|
||||
contracts = tuple(dataclasses.replace(c, preparation=state.notes[-1]) if c.kind in ('connection', 'passing') and c.preparation is None else c for c in contracts)
|
||||
if self.harmony.ordinary(state.notes[-1]):
|
||||
_,error=expressions.validate((state.notes[-1],*notes),contracts,self.harmony)
|
||||
_, error = generator.solo.expressions.validate((state.notes[-1], *notes), contracts, self.harmony)
|
||||
else:
|
||||
_,error=expressions.validate((*state.notes,*notes),(*state.contracts,*contracts),self.harmony)
|
||||
if error:return None
|
||||
history=state.history;previous=state.previous;cost=state.cost
|
||||
for a,b,p in events:
|
||||
harmonic,root,phase,source,_=self.context(a)
|
||||
action=learned.token(b-a,p,previous,root)
|
||||
context=learned.contexts(harmonic,phase,source,history)
|
||||
cost+=self.model.cost(action,context)
|
||||
_, error = generator.solo.expressions.validate((*state.notes, *notes), (*state.contracts, *contracts), self.harmony)
|
||||
if error:
|
||||
return None
|
||||
history = state.history
|
||||
previous = state.previous
|
||||
cost = state.cost
|
||||
for a, b, p in events:
|
||||
harmonic, root, phase, source, _ = self.context(a)
|
||||
action = generator.solo.learned.token(b - a, p, previous, root)
|
||||
context = generator.solo.learned.contexts(harmonic, phase, source, history)
|
||||
cost += self.model.cost(action, context)
|
||||
if p is not None:
|
||||
if previous is None:cost+=self.model.register_cost(p)
|
||||
previous=p
|
||||
history=(*history,action)[-4:]
|
||||
return State((*state.events,*events),(*state.notes,*notes),(*state.contracts,*contracts),history,previous,cost)
|
||||
|
||||
if previous is None:
|
||||
cost += self.model.register_cost(p)
|
||||
previous = p
|
||||
history = (*history, action)[-4:]
|
||||
return State((*state.events, *events), (*state.notes, *notes), (*state.contracts, *contracts), history, previous, cost)
|
||||
def compose(self):
|
||||
for attempt in range(self.attempts):
|
||||
rng=random.Random(self.seed+':'+str(attempt));state=State();time=self.start
|
||||
trace=[]
|
||||
while time<self.end:
|
||||
actions=self.actions(time)
|
||||
options=[s for a in actions if (s:=self.extend(state,a)) is not None]
|
||||
rng = random.Random(self.seed + ':' + str(attempt))
|
||||
state = State()
|
||||
time = self.start
|
||||
trace = []
|
||||
while time < self.end:
|
||||
actions = self.actions(time)
|
||||
options = [s for a in actions if (s := self.extend(state, a)) is not None]
|
||||
# Different operator names cannot multiply the probability of identical
|
||||
# sounded music. Prefer plain permission when no exception is needed.
|
||||
unique={}
|
||||
unique = {}
|
||||
for s in options:
|
||||
signature=event_key(s.events[len(state.events):])
|
||||
if signature not in unique or len(s.contracts)<len(unique[signature].contracts):unique[signature]=s
|
||||
options=sorted(unique.values(),key=lambda s:event_key(s.events[len(state.events):]))
|
||||
self.counts['scored_continuations']+=len(options)
|
||||
if not options:break
|
||||
low=min(s.cost-state.cost for s in options)
|
||||
weights=[math.exp(-(s.cost-state.cost-low)) for s in options]
|
||||
draw=rng.random()*sum(weights);selected=options[-1]
|
||||
for s,w in zip(options,weights):
|
||||
draw-=w
|
||||
if draw<=0:selected=s;break
|
||||
trace.append({'time':str(time),'valid_joint_actions':len(options),
|
||||
'selected_events':[(str(a),str(b),p) for a,b,p in selected.events[len(state.events):]],
|
||||
'negative_log_likelihood':selected.cost-state.cost})
|
||||
state=selected;time=state.events[-1][1]
|
||||
if time==self.end and state.notes:
|
||||
self.trace=trace;self.completed=(state,);self.counts['attempts']=attempt+1
|
||||
signature = event_key(s.events[len(state.events):])
|
||||
if signature not in unique or len(s.contracts) < len(unique[signature].contracts):
|
||||
unique[signature] = s
|
||||
options = sorted(unique.values(), key=lambda s: event_key(s.events[len(state.events):]))
|
||||
self.counts['scored_continuations'] += len(options)
|
||||
if not options:
|
||||
break
|
||||
low = min(s.cost - state.cost for s in options)
|
||||
weights = [math.exp(-(s.cost - state.cost - low)) for s in options]
|
||||
draw = rng.random() * sum(weights)
|
||||
selected = options[-1]
|
||||
for s, w in zip(options, weights):
|
||||
draw -= w
|
||||
if draw <= 0:
|
||||
selected = s
|
||||
break
|
||||
trace.append({'time': str(time), 'valid_joint_actions': len(options),
|
||||
'selected_events': [(str(a), str(b), p) for a, b, p in selected.events[len(state.events):]],
|
||||
'negative_log_likelihood': selected.cost - state.cost})
|
||||
state = selected
|
||||
time = state.events[-1][1]
|
||||
if time == self.end and state.notes:
|
||||
self.trace = trace
|
||||
self.completed = (state,)
|
||||
self.counts['attempts'] = attempt + 1
|
||||
return state
|
||||
raise SearchExhausted(f'{self.role}: no complete non-silent sample within {self.attempts} deterministic attempts; end={self.end}')
|
||||
|
||||
+21
-22
@@ -3,11 +3,12 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fractions import Fraction as F
|
||||
|
||||
from generator import model, musicxml_writer, score
|
||||
import pathlib
|
||||
import fractions
|
||||
|
||||
import generator.model
|
||||
import generator.musicxml_writer
|
||||
import generator.score
|
||||
|
||||
def region(document, part):
|
||||
measures = document['staves'][0]['measures']
|
||||
@@ -15,7 +16,6 @@ def region(document, part):
|
||||
end = next((i for i in range(first + 1, len(measures)) if measures[i].get('section_label')), len(measures))
|
||||
return first, end - first
|
||||
|
||||
|
||||
def excerpt(document, first, count):
|
||||
result = copy.deepcopy(document)
|
||||
staff = result['staves'][0]
|
||||
@@ -23,7 +23,7 @@ def excerpt(document, first, count):
|
||||
staff['measures'] = original[first:first + count]
|
||||
if not staff['measures']:
|
||||
raise ValueError('Empty solo export region')
|
||||
notes = [n for m in staff['measures'] for n in score.measure_notes(m)]
|
||||
notes = [n for m in staff['measures'] for n in generator.score.measure_notes(m)]
|
||||
identities = {p['id'] for n in notes for p in n['pitches']}
|
||||
events = {n['id'] for n in notes}
|
||||
for note in notes:
|
||||
@@ -36,16 +36,15 @@ def excerpt(document, first, count):
|
||||
for note in notes:
|
||||
if 'tuplet_ids' in note:
|
||||
note['tuplet_ids'] = [i for i in note['tuplet_ids'] if i in tuplets]
|
||||
offset = sum((model.fraction(m['duration']) for m in original[:first]), F())
|
||||
end = offset + sum((model.fraction(m['duration']) for m in staff['measures']), F())
|
||||
offset = sum((generator.model.fraction(m['duration']) for m in original[:first]), fractions.Fraction())
|
||||
end = offset + sum((generator.model.fraction(m['duration']) for m in staff['measures']), fractions.Fraction())
|
||||
for key in ('scale_spans', 'octave_shifts'):
|
||||
staff[key] = [{**s, 'start': model.rational(max(offset, model.fraction(s['start'])) - offset),
|
||||
'end': model.rational(min(end, model.fraction(s['end'])) - offset)} for s in staff.get(key, [])
|
||||
if model.fraction(s['start']) < end and model.fraction(s['end']) > offset]
|
||||
model.validate_document(result)
|
||||
staff[key] = [{**s, 'start': generator.model.rational(max(offset, generator.model.fraction(s['start'])) - offset),
|
||||
'end': generator.model.rational(min(end, generator.model.fraction(s['end'])) - offset)} for s in staff.get(key, [])
|
||||
if generator.model.fraction(s['start']) < end and generator.model.fraction(s['end']) > offset]
|
||||
generator.model.validate_document(result)
|
||||
return result
|
||||
|
||||
|
||||
def write_role(result, path):
|
||||
role = result['role']
|
||||
if role not in ('acoustic_solo', 'electric_solo'):
|
||||
@@ -53,25 +52,25 @@ def write_role(result, path):
|
||||
part = role.split('_')[0]
|
||||
document = result['document']
|
||||
first, count = region(document, part)
|
||||
leadin=int(result.get('details',{}).get('allocation',{}).get('lead_in_measures',0))
|
||||
first-=leadin;count+=leadin
|
||||
leadin = int(result.get('details', {}).get('allocation', {}).get('lead_in_measures', 0))
|
||||
first -= leadin
|
||||
count += leadin
|
||||
selected = excerpt(document, first, count)
|
||||
numbers = [m['number'] for m in selected['staves'][0]['measures']]
|
||||
for i, measure in enumerate(selected['staves'][0]['measures'], 1):
|
||||
measure['number'] = str(i)
|
||||
path = Path(path)
|
||||
path = pathlib.Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
musicxml_writer.write_musicxml(path, selected, tablature=True)
|
||||
return {'role': role, 'file': path.name, 'form_start_measure': 1+leadin,
|
||||
generator.musicxml_writer.write_musicxml(path, selected, tablature=True)
|
||||
return {'role': role, 'file': path.name, 'form_start_measure': 1 + leadin,
|
||||
'measure_count': count, 'arrangement_measures': numbers,
|
||||
'arrangement_start_beat': str(sum((model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first]), F())),
|
||||
'arrangement_start_beat': str(sum((generator.model.fraction(m['duration']) for m in document['staves'][0]['measures'][:first]), fractions.Fraction())),
|
||||
'pitch_convention': 'guitar-written; explicit octave transposition -1',
|
||||
'trace': result['trace'], 'phrase_report': result.get('details', {})}
|
||||
|
||||
|
||||
def export_tree(assets, output=None):
|
||||
assets = Path(assets)
|
||||
output = Path(output) if output else assets / 'solo_xml'
|
||||
assets = pathlib.Path(assets)
|
||||
output = pathlib.Path(output) if output else assets / 'solo_xml'
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
entries = {}
|
||||
for path in sorted((assets / 'analysis').glob('*.roles')):
|
||||
|
||||
+247
-181
@@ -2,205 +2,267 @@
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
from generator import harmony as chord_model, model, pitch as spelling, vocabulary
|
||||
from generator.solo import types
|
||||
import fractions
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
import generator.vocabulary
|
||||
import generator.solo.types
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def parsed_chord(symbol):
|
||||
"""Private read-only chord analysis shared by pure expression operations."""
|
||||
return chord_model.parse_symbol(symbol)
|
||||
|
||||
return generator.harmony.parse_symbol(symbol)
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def degree_spelling(letter,accidental,degree,alter):
|
||||
root={'letter':letter,'alter':model.rational(accidental),'octave':4}
|
||||
p=spelling.shift_pitch(root,degree-1,chord_model.degree_semitones(degree,alter))
|
||||
return types.Pitch(p['letter'],model.alteration(p['alter']),p['octave'])
|
||||
|
||||
def degree_spelling(letter, accidental, degree, alter):
|
||||
root = {'letter': letter, 'alter': generator.model.rational(accidental), 'octave': 4}
|
||||
p = generator.pitch.shift_pitch(root, degree - 1, generator.harmony.degree_semitones(degree, alter))
|
||||
return generator.solo.types.Pitch(p['letter'], generator.model.alteration(p['alter']), p['octave'])
|
||||
|
||||
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:
|
||||
if degrees.get(5)==-1:return 'half_diminished' if degrees.get(7)==-1 else 'diminished'
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
if degrees.get(3) == 0 and degrees.get(7) == -1:
|
||||
return 'dominant'
|
||||
if degrees.get(3) == -1:
|
||||
if degrees.get(5) == -1:
|
||||
return 'half_diminished' if degrees.get(7) == -1 else 'diminished'
|
||||
return 'minor'
|
||||
if chord['kind'] in ('suspended-second','suspended-fourth'):return chord['kind']
|
||||
if degrees.get(3)==0 and degrees.get(5)==0:return 'major'
|
||||
if chord['kind'] in ('suspended-second', 'suspended-fourth'):
|
||||
return chord['kind']
|
||||
if degrees.get(3) == 0 and degrees.get(5) == 0:
|
||||
return 'major'
|
||||
return 'other'
|
||||
|
||||
|
||||
def degree_pitch(chord,degree,alter,near):
|
||||
value=degree_spelling(chord['root']['letter'],model.alteration(chord['root']['alter']),degree,alter)
|
||||
return dataclasses.replace(value,octave=value.octave+round((near-value.number())/12))
|
||||
|
||||
def degree_pitch(chord, degree, alter, near):
|
||||
value = degree_spelling(chord['root']['letter'], generator.model.alteration(chord['root']['alter']), degree, alter)
|
||||
return dataclasses.replace(value, octave=value.octave + round((near - value.number()) / 12))
|
||||
|
||||
@functools.lru_cache(maxsize=1024)
|
||||
def chromatic(target,delta):
|
||||
def chromatic(target, delta):
|
||||
# Chromatic neighbors are spelled relative to their exact destination.
|
||||
if delta in (-3,-2,-1,1,2,3):
|
||||
step=(1 if delta>0 else -1)*(2 if abs(delta)==3 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'])
|
||||
if delta in (-3, -2, -1, 1, 2, 3):
|
||||
step = (1 if delta > 0 else -1) * (2 if abs(delta) == 3 else 1)
|
||||
root = {'letter': target.letter, 'alter': generator.model.rational(target.alter), 'octave': target.octave}
|
||||
p = generator.pitch.shift_pitch(root, step, delta)
|
||||
return generator.solo.types.Pitch(p['letter'], generator.model.alteration(p['alter']), p['octave'])
|
||||
raise ValueError('unsupported approach interval')
|
||||
|
||||
|
||||
def eligible_color(rule,span,harmony):
|
||||
def eligible_color(rule, span, harmony):
|
||||
# An explicit no-chord span (or missing harmony) has no optional colors.
|
||||
# In particular, looking ahead for an anticipation must not try to parse
|
||||
# the display label N.C. as a written chord.
|
||||
if not span or span.chord=='N.C.' or not span.chord:return False
|
||||
key=(tuple(rule['qualities']),rule['degree'],rule['alter'],rule.get('function'),span.start,span.end,span.chord)
|
||||
cache=harmony.color_eligibility
|
||||
if key not in cache:cache[key]=_eligible_color(rule,span,harmony)
|
||||
if not span or span.chord == 'N.C.' or not span.chord:
|
||||
return False
|
||||
key = (tuple(rule['qualities']), rule['degree'], rule['alter'], rule.get('function'), span.start, span.end, span.chord)
|
||||
cache = harmony.color_eligibility
|
||||
if key not in cache:
|
||||
cache[key] = _eligible_color(rule, span, harmony)
|
||||
return cache[key]
|
||||
|
||||
|
||||
def _eligible_color(rule,span,harmony):
|
||||
chord=parsed_chord(span.chord)
|
||||
degrees=chord_model.degrees(chord)
|
||||
if quality(chord) not in rule['qualities']:return False
|
||||
degree=rule['degree']; simple=(degree-1)%7+1
|
||||
def _eligible_color(rule, span, harmony):
|
||||
chord = parsed_chord(span.chord)
|
||||
degrees = generator.harmony.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.chord!='N.C.'),None)
|
||||
root=int(model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter']))%12
|
||||
next_chord=parsed_chord(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=='predominant_to_dominant' and not (next_chord and quality(next_chord)=='dominant' and (next_root-root)%12==5):return False
|
||||
if function=='resolving_dominant' and (next_root is None or (root-next_root)%12!=7):return False
|
||||
if function=='tonic_or_predominant':
|
||||
tonic=span.key.get('tonic')
|
||||
if not tonic:return False
|
||||
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
|
||||
if any((d['value'] - 1) % 7 + 1 == simple and (d['type'] == 'subtract' or generator.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.chord != 'N.C.'), None)
|
||||
root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter'])) % 12
|
||||
next_chord = parsed_chord(following.chord) if following else None
|
||||
next_root = int(generator.model.NATURAL[next_chord['root']['letter']] + generator.model.alteration(next_chord['root']['alter'])) % 12 if next_chord else None
|
||||
function = rule.get('function')
|
||||
if function == 'predominant_to_dominant' and not (next_chord and quality(next_chord) == 'dominant' and (next_root - root) % 12 == 5):
|
||||
return False
|
||||
if function == 'resolving_dominant' and (next_root is None or (root - next_root) % 12 != 7):
|
||||
return False
|
||||
if function == 'tonic_or_predominant':
|
||||
tonic = span.key.get('tonic')
|
||||
if not tonic:
|
||||
return False
|
||||
tonic_pc = int(generator.model.NATURAL[tonic['letter']] + generator.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):
|
||||
def validate(notes, contracts, harmony):
|
||||
"""Return event permissions only after checking complete actual gestures."""
|
||||
actual={n.event_id:n for n in notes};approved={}
|
||||
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)
|
||||
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.boundary is not None else c.events[0].start if c.kind=='color' else target.start)
|
||||
if not span or span.chord=='N.C.' or span.chord!=c.chord:return {},c.id+': wrong target harmony'
|
||||
if c.kind in ('anticipation','anticipated_color'):
|
||||
previous=next((s for s in harmony.spans if s.end==c.boundary),None)
|
||||
if not previous or previous.chord.strip('()')==span.chord.strip('()'):return {},c.id+': anticipation requires a harmonic change'
|
||||
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'
|
||||
arrival=c.boundary if c.boundary is not None else target.start
|
||||
if c.boundary is not None and not any(a.kind=='anticipation' and a.target==target and a.boundary==c.boundary for a in contracts):return {},c.id+': future target lacks anticipation contract'
|
||||
planned_color=any(a.kind=='color' and a.target==target and a.events==(target,) for a in contracts)
|
||||
if not planned_color and (target.pitch.number()%12 not in harmony.members(arrival,target.end) or target.pitch.number()%12 not in harmony.permitted(arrival,target.end)):return {},c.id+': invalid destination hold'
|
||||
gaps=tuple(map(F,rule.get('internal_gaps',['0']*(len(c.events)-1))))
|
||||
if len(gaps)!=len(c.events)-1 or any(g<0 for g in gaps):return {},c.id+': invalid internal approach gaps'
|
||||
gap=F(rule['gap']);cursor=target.start-gap-sum((F(d) for d in rule['durations']),F())-sum(gaps,F())
|
||||
if target.start-cursor>F(vocabulary.data('lead_ins')['max_approach']):return {},c.id+': approach too long'
|
||||
for i,(n,step,duration) in enumerate(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+(gaps[i] if i<len(gaps) else 0)
|
||||
for index in rule.get('diatonic_indices',[]):
|
||||
n=c.events[index]
|
||||
if n.pitch.number()%12 not in harmony.permitted(n.start,n.end):return {},c.id+': declared diatonic neighbor is outside source-key/chord motion'
|
||||
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=='passing':
|
||||
rule=vocabulary.data('lead_ins')['passing']
|
||||
if c.rule!=rule['id'] or len(c.events)!=1 or not c.preparation:return {},c.id+': unsupported passing figure'
|
||||
n,=c.events;before=c.preparation
|
||||
left=n.pitch.number()-before.pitch.number();right=target.pitch.number()-n.pitch.number()
|
||||
if before.end!=n.start or n.end!=target.start or n.end-n.start>F(rule['max_note']) or left!=right or left not in rule['steps']:
|
||||
return {},c.id+': interrupted/nonstepwise passing figure'
|
||||
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.boundary is not None else c.events[0].start if c.kind == 'color' else target.start)
|
||||
if not span or span.chord == 'N.C.' or span.chord != c.chord:
|
||||
return {}, c.id + ': wrong target harmony'
|
||||
if c.kind in ('anticipation', 'anticipated_color'):
|
||||
previous = next((s for s in harmony.spans if s.end == c.boundary), None)
|
||||
if not previous or previous.chord.strip('()') == span.chord.strip('()'):
|
||||
return {}, c.id + ': anticipation requires a harmonic change'
|
||||
if c.kind in ('approach', 'enclosure', 'connection'):
|
||||
rule = next((r for r in generator.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'
|
||||
arrival = c.boundary if c.boundary is not None else target.start
|
||||
if c.boundary is not None and not any(a.kind == 'anticipation' and a.target == target and a.boundary == c.boundary for a in contracts):
|
||||
return {}, c.id + ': future target lacks anticipation contract'
|
||||
planned_color = any(a.kind == 'color' and a.target == target and a.events == (target,) for a in contracts)
|
||||
if not planned_color and (target.pitch.number() % 12 not in harmony.members(arrival, target.end) or target.pitch.number() % 12 not in harmony.permitted(arrival, target.end)):
|
||||
return {}, c.id + ': invalid destination hold'
|
||||
gaps = tuple(map(fractions.Fraction, rule.get('internal_gaps', ['0'] * (len(c.events) - 1))))
|
||||
if len(gaps) != len(c.events) - 1 or any(g < 0 for g in gaps):
|
||||
return {}, c.id + ': invalid internal approach gaps'
|
||||
gap = fractions.Fraction(rule['gap'])
|
||||
cursor = target.start - gap - sum((fractions.Fraction(d) for d in rule['durations']), fractions.Fraction()) - sum(gaps, fractions.Fraction())
|
||||
if target.start - cursor > fractions.Fraction(generator.vocabulary.data('lead_ins')['max_approach']):
|
||||
return {}, c.id + ': approach too long'
|
||||
for i, (n, step, duration) in enumerate(zip(c.events, rule['semitones'], rule['durations'])):
|
||||
if n.start != cursor or n.end != cursor + fractions.Fraction(duration) or n.pitch.number() != target.pitch.number() + step:
|
||||
return {}, c.id + ': wrong register, interval, or timing'
|
||||
cursor = n.end + (gaps[i] if i < len(gaps) else 0)
|
||||
for index in rule.get('diatonic_indices', []):
|
||||
n = c.events[index]
|
||||
if n.pitch.number() % 12 not in harmony.permitted(n.start, n.end):
|
||||
return {}, c.id + ': declared diatonic neighbor is outside source-key/chord motion'
|
||||
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 == 'passing':
|
||||
rule = generator.vocabulary.data('lead_ins')['passing']
|
||||
if c.rule != rule['id'] or len(c.events) != 1 or not c.preparation:
|
||||
return {}, c.id + ': unsupported passing figure'
|
||||
n, = c.events
|
||||
before = c.preparation
|
||||
left = n.pitch.number() - before.pitch.number()
|
||||
right = target.pitch.number() - n.pitch.number()
|
||||
if before.end != n.start or n.end != target.start or n.end - n.start > fractions.Fraction(rule['max_note']) or left != right or left not in rule['steps']:
|
||||
return {}, c.id + ': interrupted/nonstepwise passing figure'
|
||||
|
||||
def supported(endpoint):
|
||||
return harmony.ordinary(endpoint) or any(a.kind in ('color','anticipated_color') and endpoint in a.events for a in contracts)
|
||||
if not supported(before) or not supported(target):return {},c.id+': unsupported passing endpoints'
|
||||
elif c.kind=='delayed_approach':
|
||||
rule=vocabulary.data('lead_ins').get('delayed_approach',{})
|
||||
if c.rule!=rule.get('id') or len(c.events)!=1:return {},c.id+': unknown delayed approach'
|
||||
n,=c.events
|
||||
if n.end-n.start>F(rule['max_note']) or not n.end<=target.start<=n.start+F(rule['deadline']):return {},c.id+': expired delayed approach'
|
||||
if target.pitch.number()-n.pitch.number() not in rule['steps']:return {},c.id+': wrong delayed target register'
|
||||
if target.pitch.number()%12 not in harmony.members(target.start,target.end) or target.pitch.number()%12 not in harmony.permitted(target.start,target.end):return {},c.id+': invalid delayed destination'
|
||||
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'
|
||||
colored=False
|
||||
return harmony.ordinary(endpoint) or any(a.kind in ('color', 'anticipated_color') and endpoint in a.events for a in contracts)
|
||||
|
||||
if not supported(before) or not supported(target):
|
||||
return {}, c.id + ': unsupported passing endpoints'
|
||||
elif c.kind == 'delayed_approach':
|
||||
rule = generator.vocabulary.data('lead_ins').get('delayed_approach', {})
|
||||
if c.rule != rule.get('id') or len(c.events) != 1:
|
||||
return {}, c.id + ': unknown delayed approach'
|
||||
n, = c.events
|
||||
if n.end - n.start > fractions.Fraction(rule['max_note']) or not n.end <= target.start <= n.start + fractions.Fraction(rule['deadline']):
|
||||
return {}, c.id + ': expired delayed approach'
|
||||
if target.pitch.number() - n.pitch.number() not in rule['steps']:
|
||||
return {}, c.id + ': wrong delayed target register'
|
||||
if target.pitch.number() % 12 not in harmony.members(target.start, target.end) or target.pitch.number() % 12 not in harmony.permitted(target.start, target.end):
|
||||
return {}, c.id + ': invalid delayed destination'
|
||||
elif c.kind == 'color':
|
||||
rule = next((r for r in generator.vocabulary.data('harmony')['colors'] if r['id'] == c.rule), None)
|
||||
if not rule:
|
||||
return {}, c.id + ': unknown color policy'
|
||||
colored = False
|
||||
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 n.pitch.number()%12 in region.roles.basic|region.roles.specified:continue
|
||||
if region.chord=='N.C.' or not eligible_color(rule,region,harmony):return {},c.id+': incompatible color harmony/function'
|
||||
p=degree_pitch(parsed_chord(region.chord),rule['degree'],rule['alter'],n.pitch.number())
|
||||
if n.end - n.start > fractions.Fraction(rule['max_beats']):
|
||||
return {}, c.id + ': excessive color duration'
|
||||
for region, a, b in harmony.segments(n):
|
||||
if n.pitch.number() % 12 in region.roles.basic | region.roles.specified:
|
||||
continue
|
||||
if region.chord == 'N.C.' or not eligible_color(rule, region, harmony):
|
||||
return {}, c.id + ': incompatible color harmony/function'
|
||||
p = degree_pitch(parsed_chord(region.chord), rule['degree'], rule['alter'], n.pitch.number())
|
||||
# The rule retains the functional degree/spelling. Encoded
|
||||
# enharmonic notation must not change sounding harmonic validity.
|
||||
if p.number()!=n.pitch.number():return {},c.id+': color degree/register mismatch'
|
||||
colored=True
|
||||
if not colored:return {},c.id+': no interval expresses the declared color'
|
||||
last=c.events[-1]
|
||||
if p.number() != n.pitch.number():
|
||||
return {}, c.id + ': color degree/register mismatch'
|
||||
colored = True
|
||||
if not colored:
|
||||
return {}, c.id + ': no interval expresses the declared color'
|
||||
last = c.events[-1]
|
||||
if rule.get('resolve'):
|
||||
if target.event_id==last.event_id 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=='suspension':
|
||||
rule=next((r for r in vocabulary.data('harmony').get('suspensions',[]) if r['id']==c.rule),None)
|
||||
if not rule or len(c.events)!=1 or c.boundary is None:return {},c.id+': unsupported suspension'
|
||||
held,=c.events
|
||||
if not held.start<=c.boundary-F(rule['preparation'])<c.boundary<held.end:
|
||||
return {},c.id+': insufficient sounding preparation'
|
||||
if held.pitch.number()%12 not in harmony.members(held.start,c.boundary):return {},c.id+': nonchordal preparation'
|
||||
if held.end-c.boundary>F(rule['max_delay']) or target.start!=held.end or target.pitch.number()-held.pitch.number() not in rule['steps']:
|
||||
return {},c.id+': missed suspension resolution'
|
||||
if target.pitch.number()%12 not in harmony.members(target.start,target.end):return {},c.id+': invalid suspension target'
|
||||
if held.pitch.number()%12 not in harmony.permitted(held.start,held.end):return {},c.id+': suspension outside source-key/chord motion'
|
||||
elif c.kind=='anticipated_color':
|
||||
rule=next((r for r in vocabulary.data('harmony')['colors'] if r['id']==c.rule),None)
|
||||
if not rule or len(c.events)!=1 or c.boundary!=span.start:return {},c.id+': unsupported anticipated color'
|
||||
n,=c.events
|
||||
if not n.start<c.boundary<n.end or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipated color interval'
|
||||
arrived=dataclasses.replace(n,start=c.boundary)
|
||||
target=arrived if c.target==n else c.target
|
||||
inner=types.ExpressionContract(c.id+':arrived','color',rule['id'],(arrived,),target,span.chord)
|
||||
_,error=validate((arrived,*((target,) if target!=arrived else ())), (inner,),harmony)
|
||||
if error:return {},error
|
||||
elif c.kind=='anticipation':
|
||||
if c.rule not in ('early_chord_tone','early_reattack') or len(c.events)!=1 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 or c.boundary-n.start>F(vocabulary.data('harmony')['anticipation_max']):return {},c.id+': invalid anticipation interval'
|
||||
if c.rule=='early_chord_tone' and (n!=target or not c.boundary<n.end):return {},c.id+': invalid held anticipation'
|
||||
if c.rule=='early_reattack' and (n.end!=c.boundary or target.start!=c.boundary or target.event_id==n.event_id or target.pitch.number()!=n.pitch.number()):return {},c.id+': invalid anticipated reattack'
|
||||
if target.pitch.number()%12 not in harmony.members(c.boundary,target.end) or target.pitch.number()%12 not in harmony.permitted(c.boundary,target.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=parsed_chord(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'
|
||||
if target.event_id == last.event_id or not 0 <= target.start - last.end <= fractions.Fraction(generator.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 == 'suspension':
|
||||
rule = next((r for r in generator.vocabulary.data('harmony').get('suspensions', []) if r['id'] == c.rule), None)
|
||||
if not rule or len(c.events) != 1 or c.boundary is None:
|
||||
return {}, c.id + ': unsupported suspension'
|
||||
held, = c.events
|
||||
if not held.start <= c.boundary - fractions.Fraction(rule['preparation']) < c.boundary < held.end:
|
||||
return {}, c.id + ': insufficient sounding preparation'
|
||||
if held.pitch.number() % 12 not in harmony.members(held.start, c.boundary):
|
||||
return {}, c.id + ': nonchordal preparation'
|
||||
if held.end - c.boundary > fractions.Fraction(rule['max_delay']) or target.start != held.end or target.pitch.number() - held.pitch.number() not in rule['steps']:
|
||||
return {}, c.id + ': missed suspension resolution'
|
||||
if target.pitch.number() % 12 not in harmony.members(target.start, target.end):
|
||||
return {}, c.id + ': invalid suspension target'
|
||||
if held.pitch.number() % 12 not in harmony.permitted(held.start, held.end):
|
||||
return {}, c.id + ': suspension outside source-key/chord motion'
|
||||
elif c.kind == 'anticipated_color':
|
||||
rule = next((r for r in generator.vocabulary.data('harmony')['colors'] if r['id'] == c.rule), None)
|
||||
if not rule or len(c.events) != 1 or c.boundary != span.start:
|
||||
return {}, c.id + ': unsupported anticipated color'
|
||||
n, = c.events
|
||||
if not n.start < c.boundary < n.end or c.boundary - n.start > fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
|
||||
return {}, c.id + ': invalid anticipated color interval'
|
||||
arrived = dataclasses.replace(n, start=c.boundary)
|
||||
target = arrived if c.target == n else c.target
|
||||
inner = generator.solo.types.ExpressionContract(c.id + ':arrived', 'color', rule['id'], (arrived,), target, span.chord)
|
||||
_, error = validate((arrived, *((target,) if target != arrived else ())), (inner,), harmony)
|
||||
if error:
|
||||
return {}, error
|
||||
elif c.kind == 'anticipation':
|
||||
if c.rule not in ('early_chord_tone', 'early_reattack') or len(c.events) != 1 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 or c.boundary - n.start > fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
|
||||
return {}, c.id + ': invalid anticipation interval'
|
||||
if c.rule == 'early_chord_tone' and (n != target or not c.boundary < n.end):
|
||||
return {}, c.id + ': invalid held anticipation'
|
||||
if c.rule == 'early_reattack' and (n.end != c.boundary or target.start != c.boundary or target.event_id == n.event_id or target.pitch.number() != n.pitch.number()):
|
||||
return {}, c.id + ': invalid anticipated reattack'
|
||||
if target.pitch.number() % 12 not in harmony.members(c.boundary, target.end) or target.pitch.number() % 12 not in harmony.permitted(c.boundary, target.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 generator.vocabulary.data('lead_ins')['blues'] if r['id'] == c.rule), None)
|
||||
n, = c.events
|
||||
chord = parsed_chord(span.chord)
|
||||
if not rule or n.end - n.start > fractions.Fraction(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 region.chord=='N.C.':return {},c.id+': missing harmony'
|
||||
covered=b
|
||||
if covered!=n.end:return {},c.id+': incomplete harmonic coverage'
|
||||
approved[n.event_id]=c
|
||||
return approved,''
|
||||
covered = n.start
|
||||
for region, a, b in harmony.segments(n):
|
||||
if a != covered or region.chord == 'N.C.':
|
||||
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 validate_frontier(notes,contracts,harmony,until,complete):
|
||||
def validate_frontier(notes, contracts, harmony, until, complete):
|
||||
"""Validate heard prefixes and bounded future obligations separately.
|
||||
|
||||
Future contract events are used ONLY to establish rule feasibility. They do
|
||||
@@ -208,21 +270,25 @@ def validate_frontier(notes,contracts,harmony,until,complete):
|
||||
target hold is present. Missing past events and expired deadlines fail.
|
||||
"""
|
||||
if complete:
|
||||
approved,error=validate(notes,contracts,harmony)
|
||||
return approved,(),error
|
||||
actual={n.event_id:n for n in notes};future=dict(actual);pending=[]
|
||||
approved, error = validate(notes, contracts, harmony)
|
||||
return approved, (), error
|
||||
actual = {n.event_id: n for n in notes}
|
||||
future = dict(actual)
|
||||
pending = []
|
||||
for c in contracts:
|
||||
required=tuple(dict.fromkeys((*c.events,c.target,*((c.preparation,) if c.preparation else ()))))
|
||||
incomplete=False
|
||||
required = tuple(dict.fromkeys((*c.events, c.target, *((c.preparation,) if c.preparation else ()))))
|
||||
incomplete = False
|
||||
for expected in required:
|
||||
heard=actual.get(expected.event_id)
|
||||
if expected.start>=until:
|
||||
future[expected.event_id]=expected;incomplete=True
|
||||
elif heard and heard.pitch==expected.pitch and heard.start==expected.start and heard.end==min(until,expected.end):
|
||||
future[expected.event_id]=expected
|
||||
incomplete |= heard.end<expected.end
|
||||
elif not heard or (heard.start,heard.end,heard.pitch)!=(expected.start,expected.end,expected.pitch):
|
||||
return {},(),c.id+': missing/changed event before resolution deadline'
|
||||
if incomplete:pending.append(c)
|
||||
approved,error=validate(tuple(sorted(future.values(),key=lambda n:(n.start,n.event_id))),contracts,harmony)
|
||||
return {key:c for key,c in approved.items() if key in actual},tuple(pending),error
|
||||
heard = actual.get(expected.event_id)
|
||||
if expected.start >= until:
|
||||
future[expected.event_id] = expected
|
||||
incomplete = True
|
||||
elif heard and heard.pitch == expected.pitch and heard.start == expected.start and heard.end == min(until, expected.end):
|
||||
future[expected.event_id] = expected
|
||||
incomplete |= heard.end < expected.end
|
||||
elif not heard or (heard.start, heard.end, heard.pitch) != (expected.start, expected.end, expected.pitch):
|
||||
return {}, (), c.id + ': missing/changed event before resolution deadline'
|
||||
if incomplete:
|
||||
pending.append(c)
|
||||
approved, error = validate(tuple(sorted(future.values(), key=lambda n: (n.start, n.event_id))), contracts, harmony)
|
||||
return {key: c for key, c in approved.items() if key in actual}, tuple(pending), error
|
||||
|
||||
+47
-39
@@ -7,59 +7,66 @@ not a rhythmic boundary and must not be used to clip a group.
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
|
||||
import fractions
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RhythmGroup:
|
||||
start: F
|
||||
end: F
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
family: str
|
||||
events: tuple[tuple[F, F], ...]
|
||||
displacement: F = F()
|
||||
events: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
|
||||
displacement: fractions.Fraction = fractions.Fraction()
|
||||
source: str = ''
|
||||
|
||||
|
||||
def describe(pattern, *, origin=F(), source='', imported=False):
|
||||
if not pattern:return ()
|
||||
def describe(pattern, *, origin=fractions.Fraction(), source='', imported=False):
|
||||
if not pattern:
|
||||
return ()
|
||||
if imported:
|
||||
return (RhythmGroup(origin, origin+pattern[-1][1], 'source', pattern, source=source),)
|
||||
result=[]; i=0
|
||||
while i<len(pattern):
|
||||
a,b=pattern[i]
|
||||
if b<=a or i and a<pattern[i-1][1]:raise ValueError('overlapping rhythm group')
|
||||
return (RhythmGroup(origin, origin + pattern[-1][1], 'source', pattern, source=source),)
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(pattern):
|
||||
a, b = pattern[i]
|
||||
if b <= a or i and a < pattern[i - 1][1]:
|
||||
raise ValueError('overlapping rhythm group')
|
||||
# A complete ternary figure can be intentionally displaced on an eighth.
|
||||
if (b-a).denominator%3==0:
|
||||
if (a*2).denominator!=1:raise ValueError('unexplained tuplet entry phase')
|
||||
start=i; cursor=a
|
||||
while i<len(pattern):
|
||||
x,y=pattern[i]
|
||||
if x!=cursor or (y-x).denominator not in (3,6):break
|
||||
cursor=y;i+=1
|
||||
if (cursor-a).denominator==1:break
|
||||
if i==start or (cursor-a).denominator!=1:
|
||||
if (b - a).denominator % 3 == 0:
|
||||
if (a * 2).denominator != 1:
|
||||
raise ValueError('unexplained tuplet entry phase')
|
||||
start = i
|
||||
cursor = a
|
||||
while i < len(pattern):
|
||||
x, y = pattern[i]
|
||||
if x != cursor or (y - x).denominator not in (3, 6):
|
||||
break
|
||||
cursor = y
|
||||
i += 1
|
||||
if (cursor - a).denominator == 1:
|
||||
break
|
||||
if i == start or (cursor - a).denominator != 1:
|
||||
raise ValueError('incomplete tuplet group')
|
||||
group=pattern[start:i]
|
||||
result.append(RhythmGroup(origin+a,origin+cursor,'triplet',group,a%1,source))
|
||||
group = pattern[start:i]
|
||||
result.append(RhythmGroup(origin + a, origin + cursor, 'triplet', group, a % 1, source))
|
||||
else:
|
||||
if (a*4).denominator!=1 or (b*4).denominator!=1:
|
||||
if (a * 4).denominator != 1 or (b * 4).denominator != 1:
|
||||
raise ValueError('unexplained binary subdivision phase')
|
||||
family='sixteenth' if (a*2).denominator!=1 or (b*2).denominator!=1 else 'binary'
|
||||
result.append(RhythmGroup(origin+a,origin+b,family,((a,b),),a%1,source));i+=1
|
||||
family = 'sixteenth' if (a * 2).denominator != 1 or (b * 2).denominator != 1 else 'binary'
|
||||
result.append(RhythmGroup(origin + a, origin + b, family, ((a, b),), a % 1, source))
|
||||
i += 1
|
||||
return tuple(result)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def valid(pattern):
|
||||
try:describe(pattern);return True
|
||||
except ValueError:return False
|
||||
|
||||
try:
|
||||
describe(pattern)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def signature(notes):
|
||||
"""Translation-invariant duration, inter-onset and rest vocabulary."""
|
||||
return tuple((n.end-n.start, notes[i+1].start-n.end if i+1<len(notes) else F())
|
||||
for i,n in enumerate(notes))
|
||||
|
||||
return tuple((n.end - n.start, notes[i + 1].start - n.end if i + 1 < len(notes) else fractions.Fraction())
|
||||
for i, n in enumerate(notes))
|
||||
|
||||
def descriptor(pattern, duration):
|
||||
"""Retain ordered attacks, durations and space, including a trailing breath.
|
||||
@@ -69,11 +76,12 @@ def descriptor(pattern, duration):
|
||||
"""
|
||||
return (duration, tuple(pattern))
|
||||
|
||||
|
||||
def broaden(pattern, arrival, release):
|
||||
"""Reopen an editable binary tail; never slice a tuplet or inherit its phase."""
|
||||
if (arrival*2).denominator!=1 or (release*2).denominator!=1:return ()
|
||||
if (arrival * 2).denominator != 1 or (release * 2).denominator != 1:
|
||||
return ()
|
||||
for group in describe(pattern):
|
||||
if group.family=='triplet' and group.start<arrival<group.end:return ()
|
||||
result=tuple((a,min(b,arrival)) for a,b in pattern if a<arrival)+((arrival,release),)
|
||||
if group.family == 'triplet' and group.start < arrival < group.end:
|
||||
return ()
|
||||
result = tuple((a, min(b, arrival)) for a, b in pattern if a < arrival) + ((arrival, release),)
|
||||
return result if valid(result) else ()
|
||||
|
||||
+124
-98
@@ -6,127 +6,153 @@ permission follows the actual sounded interval and validated resolution.
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
from generator import harmony as chords, model
|
||||
from generator.solo import types
|
||||
import fractions
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.solo.types
|
||||
import generator.solo.profiles
|
||||
import generator.solo.expressions
|
||||
import generator.solo.recognition
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Roles:
|
||||
basic:frozenset
|
||||
specified:frozenset
|
||||
degrees:tuple
|
||||
basic: frozenset
|
||||
specified: frozenset
|
||||
degrees: tuple
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def degree_roles(kind,root,degrees):
|
||||
basic,specified,labels=set(),set(),{}
|
||||
for degree,alter in degrees:
|
||||
pc=int(root+chords.degree_semitones(degree,alter))%12
|
||||
structural=degree in (1,3,5,6,7) or degree in (2,4) and kind in ('suspended-second','suspended-fourth')
|
||||
(basic if structural else specified).add(pc);labels.setdefault(pc,[]).append(degree)
|
||||
return Roles(frozenset(basic),frozenset(specified-basic),tuple((p,tuple(v)) for p,v in sorted(labels.items())))
|
||||
def degree_roles(kind, root, degrees):
|
||||
basic, specified, labels = set(), set(), {}
|
||||
for degree, alter in degrees:
|
||||
pc = int(root + generator.harmony.degree_semitones(degree, alter)) % 12
|
||||
structural = degree in (1, 3, 5, 6, 7) or degree in (2, 4) and kind in ('suspended-second', 'suspended-fourth')
|
||||
(basic if structural else specified).add(pc)
|
||||
labels.setdefault(pc, []).append(degree)
|
||||
return Roles(frozenset(basic), frozenset(specified - basic), tuple((p, tuple(v)) for p, v in sorted(labels.items())))
|
||||
|
||||
def roles(chord):
|
||||
if not chord or chord['kind']=='none':return Roles(frozenset(),frozenset(),())
|
||||
root=chord['root'];pc=model.NATURAL[root['letter']]+model.alteration(root['alter'])
|
||||
return degree_roles(chord['kind'],pc,tuple(sorted(chords.degrees(chord).items())))
|
||||
if not chord or chord['kind'] == 'none':
|
||||
return Roles(frozenset(), frozenset(), ())
|
||||
root = chord['root']
|
||||
pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
|
||||
return degree_roles(chord['kind'], pc, tuple(sorted(generator.harmony.degrees(chord).items())))
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Span:
|
||||
start:F
|
||||
end:F
|
||||
roles:Roles
|
||||
chord:str
|
||||
key:dict
|
||||
pitches:frozenset
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
roles: Roles
|
||||
chord: str
|
||||
key: dict
|
||||
pitches: frozenset
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Result:
|
||||
valid:bool
|
||||
exposure:float
|
||||
pending:tuple=()
|
||||
error:str=''
|
||||
contracts:tuple=()
|
||||
valid: bool
|
||||
exposure: float
|
||||
pending: tuple = ()
|
||||
error: str = ''
|
||||
contracts: tuple = ()
|
||||
|
||||
class Harmony:
|
||||
def __init__(self,spans,profile=None):
|
||||
from generator.solo.profiles import UNRESTRICTED
|
||||
self.profile=profile or UNRESTRICTED
|
||||
self.source_spans=spans;self.spans=[];self.color_eligibility={}
|
||||
def __init__(self, spans, profile=None):
|
||||
self.profile = profile or generator.solo.profiles.UNRESTRICTED
|
||||
self.source_spans = spans
|
||||
self.spans = []
|
||||
self.color_eligibility = {}
|
||||
for s in spans:
|
||||
r=roles(s.chord)
|
||||
key=frozenset((model.NATURAL[l]+int(model.fraction(a)))%12 for l,a in zip(model.LETTERS,s.key['alterations']))
|
||||
self.spans.append(Span(s.start,s.end,r,chords.symbol(s.chord) if s.chord else 'N.C.',s.key,key|r.basic|r.specified))
|
||||
self.spans=tuple(self.spans)
|
||||
r = roles(s.chord)
|
||||
key = frozenset((generator.model.NATURAL[l] + int(generator.model.fraction(a))) % 12 for l, a in zip(generator.model.LETTERS, s.key['alterations']))
|
||||
self.spans.append(Span(s.start, s.end, r, generator.harmony.symbol(s.chord) if s.chord else 'N.C.', s.key, key | r.basic | r.specified))
|
||||
self.spans = tuple(self.spans)
|
||||
@functools.lru_cache(maxsize=2048)
|
||||
def at(self,time):return next((s for s in self.spans if s.start<=time<s.end),None)
|
||||
def segments(self,n):return tuple((s,max(n.start,s.start),min(n.end,s.end)) for s in self.spans if s.start<n.end and s.end>n.start)
|
||||
def at(self, time): return next((s for s in self.spans if s.start <= time < s.end), None)
|
||||
def segments(self, n): return tuple((s, max(n.start, s.start), min(n.end, s.end)) for s in self.spans if s.start < n.end and s.end > n.start)
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def members(self,start,end):
|
||||
spans=[s for s in self.spans if s.start<end and s.end>start]
|
||||
return frozenset.intersection(*(s.roles.basic|s.roles.specified for s in spans)) if spans else frozenset()
|
||||
def members(self, start, end):
|
||||
spans = [s for s in self.spans if s.start < end and s.end > start]
|
||||
return frozenset.intersection(*(s.roles.basic | s.roles.specified for s in spans)) if spans else frozenset()
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def permitted(self,start,end):
|
||||
spans=[s for s in self.spans if s.start<end and s.end>start]
|
||||
return frozenset.intersection(*(s.pitches if s.chord!='N.C.' else frozenset() for s in spans)) if spans else frozenset()
|
||||
def function(self,number,span):
|
||||
pc=number%12
|
||||
def permitted(self, start, end):
|
||||
spans = [s for s in self.spans if s.start < end and s.end > start]
|
||||
return frozenset.intersection(*(s.pitches if s.chord != 'N.C.' else frozenset() for s in spans)) if spans else frozenset()
|
||||
def function(self, number, span):
|
||||
pc = number % 12
|
||||
return 'basic' if pc in span.roles.basic else 'specified_extension' if pc in span.roles.specified else 'diatonic_motion' if pc in span.pitches else 'chromatic'
|
||||
def step(self,a,b,time):return 0<abs(a-b)<=2
|
||||
def connected_motion(self,notes,index):
|
||||
if not 0<index<len(notes)-1:return ''
|
||||
a,n,b=notes[index-1:index+2]
|
||||
if a.end!=n.start or n.end!=b.start:return ''
|
||||
left=n.pitch.number()-a.pitch.number();right=b.pitch.number()-n.pitch.number()
|
||||
return 'neighbor' if 0<abs(left)<=2 and left==-right else 'passing' if 0<abs(left)<=2 and 0<abs(right)<=2 and left*right>0 else ''
|
||||
def step(self, a, b, time): return 0 < abs(a - b) <= 2
|
||||
def connected_motion(self, notes, index):
|
||||
if not 0 < index < len(notes) - 1:
|
||||
return ''
|
||||
a, n, b = notes[index - 1:index + 2]
|
||||
if a.end != n.start or n.end != b.start:
|
||||
return ''
|
||||
left = n.pitch.number() - a.pitch.number()
|
||||
right = b.pitch.number() - n.pitch.number()
|
||||
return 'neighbor' if 0 < abs(left) <= 2 and left == -right else 'passing' if 0 < abs(left) <= 2 and 0 < abs(right) <= 2 and left * right > 0 else ''
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def _ordinary(self,start,end,number):
|
||||
covered=start
|
||||
def _ordinary(self, start, end, number):
|
||||
covered = start
|
||||
for s in self.spans:
|
||||
a,b=max(start,s.start),min(end,s.end)
|
||||
if a>=b:continue
|
||||
if a!=covered or s.chord=='N.C.':return False
|
||||
covered=b;pc=number%12
|
||||
if pc not in s.roles.basic|s.roles.specified and (pc not in s.pitches or end-start>=2):return False
|
||||
return covered==end and end>start
|
||||
def ordinary(self,n):return self._ordinary(n.start,n.end,n.pitch.number())
|
||||
a, b = max(start, s.start), min(end, s.end)
|
||||
if a >= b:
|
||||
continue
|
||||
if a != covered or s.chord == 'N.C.':
|
||||
return False
|
||||
covered = b
|
||||
pc = number % 12
|
||||
if pc not in s.roles.basic | s.roles.specified and (pc not in s.pitches or end - start >= 2):
|
||||
return False
|
||||
return covered == end and end > start
|
||||
def ordinary(self, n): return self._ordinary(n.start, n.end, n.pitch.number())
|
||||
@staticmethod
|
||||
def identified(n):
|
||||
return n if n.event_id else dataclasses.replace(n,event_id=f'event:{n.start}:{n.end}:{n.pitch.label()}')
|
||||
def evaluate(self,notes,*,until=None,complete=False,expressions=(),infer=True):
|
||||
from generator.solo.expressions import validate_frontier
|
||||
notes=tuple(self.identified(n) for n in types.sounding_notes(notes))
|
||||
expressions=tuple(dataclasses.replace(c,events=tuple(self.identified(n) for n in c.events),
|
||||
target=self.identified(c.target),preparation=self.identified(c.preparation) if c.preparation else None) for c in expressions)
|
||||
frontier=until if until is not None else max((n.end for n in notes),default=F())
|
||||
permissions,pending,error=validate_frontier(notes,expressions,self,frontier,complete or until is None)
|
||||
if error:return Result(False,0,error=error)
|
||||
return n if n.event_id else dataclasses.replace(n, event_id=f'event:{n.start}:{n.end}:{n.pitch.label()}')
|
||||
def evaluate(self, notes, *, until=None, complete=False, expressions=(), infer=True):
|
||||
notes = tuple(self.identified(n) for n in generator.solo.types.sounding_notes(notes))
|
||||
expressions = tuple(dataclasses.replace(c, events=tuple(self.identified(n) for n in c.events),
|
||||
target=self.identified(c.target), preparation=self.identified(c.preparation) if c.preparation else None) for c in expressions)
|
||||
frontier = until if until is not None else max((n.end for n in notes), default=fractions.Fraction())
|
||||
permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, complete or until is None)
|
||||
if error:
|
||||
return Result(False, 0, error=error)
|
||||
if infer and not pending and (complete or until is None) and any(not self.ordinary(n) and n.event_id not in permissions for n in notes):
|
||||
from generator.solo.recognition import recognize
|
||||
expressions,error=recognize(notes,self,expressions)
|
||||
if error:return Result(False,0,error=error)
|
||||
permissions,pending,error=validate_frontier(notes,expressions,self,frontier,True)
|
||||
if error:return Result(False,0,error=error)
|
||||
exposed=sounded=F()
|
||||
expressions, error = generator.solo.recognition.recognize(notes, self, expressions)
|
||||
if error:
|
||||
return Result(False, 0, error=error)
|
||||
permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, True)
|
||||
if error:
|
||||
return Result(False, 0, error=error)
|
||||
exposed = sounded = fractions.Fraction()
|
||||
for n in notes:
|
||||
covered=n.start;duration=n.end-n.start;sounded+=duration
|
||||
for s,a,b in self.segments(n):
|
||||
if a!=covered or s.chord=='N.C.':return Result(False,0,error=f'{n.event_id}: missing written/carried harmony')
|
||||
covered=b;permission=permissions.get(n.event_id)
|
||||
if permission and permission.kind=='anticipation' and a>=permission.boundary:permission=None
|
||||
pc=n.pitch.number()%12
|
||||
if pc in s.roles.basic|s.roles.specified or permission:continue
|
||||
if pc not in s.pitches:return Result(False,0,error=f'{n.event_id}: unsupported chromatic event')
|
||||
if duration>=2:return Result(False,0,error=f'{n.event_id}: sustained nonchord tone needs an explicit treatment')
|
||||
exposed+=(b-a)/4
|
||||
if covered!=n.end:return Result(False,0,error=f'{n.event_id}: incomplete harmonic coverage')
|
||||
return Result(True,float(exposed/max(F(1,1000),sounded)),pending,contracts=tuple(expressions))
|
||||
def describe(self,notes,expressions=()):
|
||||
notes=tuple(self.identified(n) for n in types.sounding_notes(notes))
|
||||
checked=self.evaluate(notes,expressions=expressions,complete=True)
|
||||
if not checked.valid:raise ValueError(checked.error)
|
||||
permissions={n.event_id:c for c in checked.contracts for n in c.events}
|
||||
return [{'start':str(a),'end':str(b),'attack':str(n.start),'sounding_duration':str(n.end-n.start),
|
||||
'pitch':n.pitch.label(),'chord':s.chord,'function':self.function(n.pitch.number(),s),
|
||||
'expression':permissions[n.event_id].kind if n.event_id in permissions else '',
|
||||
'expression_rule':permissions[n.event_id].rule if n.event_id in permissions else '',
|
||||
'protected':n.role=='quote'} for n in notes for s,a,b in self.segments(n)]
|
||||
covered = n.start
|
||||
duration = n.end - n.start
|
||||
sounded += duration
|
||||
for s, a, b in self.segments(n):
|
||||
if a != covered or s.chord == 'N.C.':
|
||||
return Result(False, 0, error=f'{n.event_id}: missing written/carried harmony')
|
||||
covered = b
|
||||
permission = permissions.get(n.event_id)
|
||||
if permission and permission.kind == 'anticipation' and a >= permission.boundary:
|
||||
permission = None
|
||||
pc = n.pitch.number() % 12
|
||||
if pc in s.roles.basic | s.roles.specified or permission:
|
||||
continue
|
||||
if pc not in s.pitches:
|
||||
return Result(False, 0, error=f'{n.event_id}: unsupported chromatic event')
|
||||
if duration >= 2:
|
||||
return Result(False, 0, error=f'{n.event_id}: sustained nonchord tone needs an explicit treatment')
|
||||
exposed += (b - a) / 4
|
||||
if covered != n.end:
|
||||
return Result(False, 0, error=f'{n.event_id}: incomplete harmonic coverage')
|
||||
return Result(True, float(exposed / max(fractions.Fraction(1, 1000), sounded)), pending, contracts=tuple(expressions))
|
||||
def describe(self, notes, expressions=()):
|
||||
notes = tuple(self.identified(n) for n in generator.solo.types.sounding_notes(notes))
|
||||
checked = self.evaluate(notes, expressions=expressions, complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError(checked.error)
|
||||
permissions = {n.event_id: c for c in checked.contracts for n in c.events}
|
||||
return [{'start': str(a), 'end': str(b), 'attack': str(n.start), 'sounding_duration': str(n.end - n.start),
|
||||
'pitch': n.pitch.label(), 'chord': s.chord, 'function': self.function(n.pitch.number(), s),
|
||||
'expression': permissions[n.event_id].kind if n.event_id in permissions else '',
|
||||
'expression_rule': permissions[n.event_id].rule if n.event_id in permissions else '',
|
||||
'protected': n.role == 'quote'} for n in notes for s, a, b in self.segments(n)]
|
||||
|
||||
+97
-92
@@ -9,122 +9,127 @@ import collections
|
||||
import json
|
||||
import math
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
from pathlib import Path
|
||||
from generator import model
|
||||
from generator.solo import expressions
|
||||
import fractions
|
||||
import pathlib
|
||||
import generator.model
|
||||
import generator.solo.expressions
|
||||
|
||||
VERSION = 'pooled-events-1'
|
||||
PRIOR = 8
|
||||
DURATIONS = ('1/4','1/3','1/2','2/3','3/4','1','3/2','2','5/2','3','4')
|
||||
|
||||
DURATIONS = ('1/4', '1/3', '1/2', '2/3', '3/4', '1', '3/2', '2', '5/2', '3', '4')
|
||||
|
||||
def key(value):
|
||||
return json.dumps(value,separators=(',',':'))
|
||||
return json.dumps(value, separators=(',', ':'))
|
||||
|
||||
def chord_context(harmony, time):
|
||||
span = harmony.at(time)
|
||||
current = generator.solo.expressions.parsed_chord(span.chord) if span and span.chord != 'N.C.' else None
|
||||
future = next((s for s in harmony.spans if s.start > time and s.chord != 'N.C.' and (not span or s.chord != span.chord)), None)
|
||||
following = generator.solo.expressions.parsed_chord(future.chord) if future else None
|
||||
|
||||
def chord_context(harmony,time):
|
||||
span=harmony.at(time)
|
||||
current=expressions.parsed_chord(span.chord) if span and span.chord!='N.C.' else None
|
||||
future=next((s for s in harmony.spans if s.start>time and s.chord!='N.C.' and (not span or s.chord!=span.chord)),None)
|
||||
following=expressions.parsed_chord(future.chord) if future else None
|
||||
def root(c):return int(model.NATURAL[c['root']['letter']]+model.alteration(c['root']['alter']))%12 if c else 0
|
||||
return (expressions.quality(current) if current else 'unknown',
|
||||
expressions.quality(following) if following else 'end',
|
||||
(root(following)-root(current))%12 if current and following else -1),root(current),future
|
||||
def root(c): return int(generator.model.NATURAL[c['root']['letter']] + generator.model.alteration(c['root']['alter'])) % 12 if c else 0
|
||||
|
||||
return (generator.solo.expressions.quality(current) if current else 'unknown',
|
||||
generator.solo.expressions.quality(following) if following else 'end',
|
||||
(root(following) - root(current)) % 12 if current and following else -1), root(current), future
|
||||
|
||||
def token(duration,pitch,previous,root):
|
||||
return ('rest',str(duration),0,0) if pitch is None else ('note',str(duration),
|
||||
pitch-previous if previous is not None else 'start',(pitch-root)%12)
|
||||
def token(duration, pitch, previous, root):
|
||||
return ('rest', str(duration), 0, 0) if pitch is None else ('note', str(duration), pitch - previous if previous is not None else 'start', (pitch - root) % 12)
|
||||
|
||||
def source_feature(analysis, time, root=0):
|
||||
time += analysis.body_offset
|
||||
before = [n for n in analysis.melody if n.start <= time][-2:]
|
||||
following = next((n for n in analysis.melody if n.start > time), None)
|
||||
direction = 0 if len(before) < 2 else (before[-1].pitch.number() > before[-2].pitch.number()) - (before[-1].pitch.number() < before[-2].pitch.number())
|
||||
held = bool(before and before[-1].end > time)
|
||||
upcoming = 0 if not before or not following else (following.pitch.number() > before[-1].pitch.number()) - (following.pitch.number() < before[-1].pitch.number())
|
||||
return (direction, held, upcoming, str(before[-1].end - before[-1].start) if before else 'none',
|
||||
(before[-1].pitch.number() - root) % 12 if before else -1,
|
||||
before[-1].pitch.number() - before[-2].pitch.number() if len(before) > 1 else 0)
|
||||
|
||||
def source_feature(analysis,time,root=0):
|
||||
time+=analysis.body_offset
|
||||
before=[n for n in analysis.melody if n.start<=time][-2:]
|
||||
following=next((n for n in analysis.melody if n.start>time),None)
|
||||
direction=0 if len(before)<2 else (before[-1].pitch.number()>before[-2].pitch.number())-(before[-1].pitch.number()<before[-2].pitch.number())
|
||||
held=bool(before and before[-1].end>time)
|
||||
upcoming=0 if not before or not following else (following.pitch.number()>before[-1].pitch.number())-(following.pitch.number()<before[-1].pitch.number())
|
||||
return (direction,held,upcoming,str(before[-1].end-before[-1].start) if before else 'none',
|
||||
(before[-1].pitch.number()-root)%12 if before else -1,
|
||||
before[-1].pitch.number()-before[-2].pitch.number() if len(before)>1 else 0)
|
||||
|
||||
|
||||
def phase_at(analysis,time):
|
||||
absolute=time+analysis.body_offset
|
||||
start=next((a for a,b in zip(analysis.boundaries,analysis.boundaries[1:]) if a<=absolute<b),analysis.boundaries[-2])
|
||||
return absolute-start
|
||||
|
||||
def phase_at(analysis, time):
|
||||
absolute = time + analysis.body_offset
|
||||
start = next((a for a, b in zip(analysis.boundaries, analysis.boundaries[1:]) if a <= absolute < b), analysis.boundaries[-2])
|
||||
return absolute - start
|
||||
|
||||
@functools.lru_cache(maxsize=65536)
|
||||
def contexts(harmonic,phase,source,history):
|
||||
def contexts(harmonic, phase, source, history):
|
||||
# No title, path, absolute bar number, progression ID, or target-derived ID.
|
||||
base=(*harmonic,str(phase))
|
||||
result=[(),base,(*base,source)]
|
||||
base = (*harmonic, str(phase))
|
||||
result = [(), base, (*base, source)]
|
||||
# A complete four-event rhythmic/melodic relationship can influence the next
|
||||
# event, while unseen histories smoothly return to broader musical evidence.
|
||||
for count in range(1,min(4,len(history))+1):
|
||||
result.append((*base,source,tuple(history[-count:])))
|
||||
for count in range(1, min(4, len(history)) + 1):
|
||||
result.append((*base, source, tuple(history[-count:])))
|
||||
return tuple(key(c) for c in result)
|
||||
|
||||
|
||||
class Model:
|
||||
def __init__(self,value):
|
||||
if value.get('version')!=VERSION:raise ValueError('Missing/incompatible solo model; run python -m generator.train')
|
||||
self.value=value
|
||||
self.tables={k:(v['total'],v['counts']) for k,v in value['contexts'].items()}
|
||||
self.durations=tuple(sorted(map(F,value['durations'])))
|
||||
self.rhythms=value['rhythms'];self.steps=value['steps'];self.degrees=value['degrees']
|
||||
self._prob={}
|
||||
self._totals={id(t):sum(t.values()) for t in (self.rhythms,self.steps,self.degrees)}
|
||||
|
||||
def __init__(self, value):
|
||||
if value.get('version') != VERSION:
|
||||
raise ValueError('Missing/incompatible solo model; run python -m generator.train')
|
||||
self.value = value
|
||||
self.tables = {k: (v['total'], v['counts']) for k, v in value['contexts'].items()}
|
||||
self.durations = tuple(sorted(map(fractions.Fraction, value['durations'])))
|
||||
self.rhythms = value['rhythms']
|
||||
self.steps = value['steps']
|
||||
self.degrees = value['degrees']
|
||||
self._prob = {}
|
||||
self._totals = {id(t): sum(t.values()) for t in (self.rhythms, self.steps, self.degrees)}
|
||||
@classmethod
|
||||
def load(cls,path):
|
||||
try:return cls(json.loads(Path(path).read_text()))
|
||||
except FileNotFoundError as e:raise ValueError('Solo model not found; run python -m generator.train') from e
|
||||
|
||||
def probability(self,action,context):
|
||||
cache=(action,context)
|
||||
if cache in self._prob:return self._prob[cache]
|
||||
kind,duration,step,degree=action
|
||||
def p(table,item,size):return (table.get(str(item),0)+1)/(self._totals[id(table)]+size)
|
||||
probability=p(self.rhythms,kind+':'+duration,2*len(self.durations))
|
||||
if kind=='note':probability*=p(self.steps,step,50)*p(self.degrees,degree,12)
|
||||
encoded=key(action)
|
||||
def load(cls, path):
|
||||
try:
|
||||
return cls(json.loads(pathlib.Path(path).read_text()))
|
||||
except FileNotFoundError as e:
|
||||
raise ValueError('Solo model not found; run python -m generator.train') from e
|
||||
def probability(self, action, context):
|
||||
cache = (action, context)
|
||||
if cache in self._prob:
|
||||
return self._prob[cache]
|
||||
kind, duration, step, degree = action
|
||||
def p(table, item, size): return (table.get(str(item), 0) + 1) / (self._totals[id(table)] + size)
|
||||
probability = p(self.rhythms, kind + ':' + duration, 2 * len(self.durations))
|
||||
if kind == 'note':
|
||||
probability *= p(self.steps, step, 50) * p(self.degrees, degree, 12)
|
||||
encoded = key(action)
|
||||
for c in context:
|
||||
if c in self.tables:
|
||||
total,counts=self.tables[c]
|
||||
probability=(counts.get(encoded,0)+PRIOR*probability)/(total+PRIOR)
|
||||
if len(self._prob)>100000:self._prob.clear()
|
||||
self._prob[cache]=probability
|
||||
total, counts = self.tables[c]
|
||||
probability = (counts.get(encoded, 0) + PRIOR * probability) / (total + PRIOR)
|
||||
if len(self._prob) > 100000:
|
||||
self._prob.clear()
|
||||
self._prob[cache] = probability
|
||||
return probability
|
||||
def cost(self, action, context): return -math.log(self.probability(action, context))
|
||||
def register_cost(self, number):
|
||||
counts = self.value['registers']
|
||||
return -math.log((counts.get(str(number // 12), 0) + PRIOR) / (sum(counts.values()) + PRIOR * 4))
|
||||
|
||||
def cost(self,action,context):return -math.log(self.probability(action,context))
|
||||
|
||||
def register_cost(self,number):
|
||||
counts=self.value['registers']
|
||||
return -math.log((counts.get(str(number//12),0)+PRIOR)/(sum(counts.values())+PRIOR*4))
|
||||
|
||||
|
||||
def fit(examples,provenance):
|
||||
tables=collections.defaultdict(collections.Counter)
|
||||
rhythms=collections.Counter();steps=collections.Counter();degrees=collections.Counter()
|
||||
registers=collections.Counter();durations=set(DURATIONS)
|
||||
def fit(examples, provenance):
|
||||
tables = collections.defaultdict(collections.Counter)
|
||||
rhythms = collections.Counter()
|
||||
steps = collections.Counter()
|
||||
degrees = collections.Counter()
|
||||
registers = collections.Counter()
|
||||
durations = set(DURATIONS)
|
||||
for example in examples:
|
||||
history=[];previous=None
|
||||
for start,end,pitch in example['events']:
|
||||
harmonic,root,_=chord_context(example['harmony'],start)
|
||||
action=token(end-start,pitch,previous,root)
|
||||
context=contexts(harmonic,phase_at(example['analysis'],start-example['analysis'].body_offset),
|
||||
source_feature(example['analysis'],start-example['analysis'].body_offset,root),tuple(history[-4:]))
|
||||
for c in context:tables[c][key(action)]+=1
|
||||
durations.add(str(end-start));rhythms[action[0]+':'+action[1]]+=1
|
||||
history = []
|
||||
previous = None
|
||||
for start, end, pitch in example['events']:
|
||||
harmonic, root, _ = chord_context(example['harmony'], start)
|
||||
action = token(end - start, pitch, previous, root)
|
||||
context = contexts(harmonic, phase_at(example['analysis'], start - example['analysis'].body_offset),
|
||||
source_feature(example['analysis'], start - example['analysis'].body_offset, root), tuple(history[-4:]))
|
||||
for c in context:
|
||||
tables[c][key(action)] += 1
|
||||
durations.add(str(end - start))
|
||||
rhythms[action[0] + ':' + action[1]] += 1
|
||||
if pitch is not None:
|
||||
steps[str(action[2])]+=1;degrees[str(action[3])]+=1
|
||||
registers[str(pitch//12)]+=1;previous=pitch
|
||||
steps[str(action[2])] += 1
|
||||
degrees[str(action[3])] += 1
|
||||
registers[str(pitch // 12)] += 1
|
||||
previous = pitch
|
||||
history.append(action)
|
||||
return {'version':VERSION,'provenance':provenance,'prior':PRIOR,
|
||||
'durations':sorted(durations,key=F),'rhythms':dict(rhythms),'steps':dict(steps),'degrees':dict(degrees),
|
||||
'registers':dict(registers),
|
||||
'contexts':{k:{'total':sum(v.values()),'counts':dict(sorted(v.items()))} for k,v in sorted(tables.items())}}
|
||||
return {'version': VERSION, 'provenance': provenance, 'prior': PRIOR,
|
||||
'durations': sorted(durations, key=fractions.Fraction), 'rhythms': dict(rhythms), 'steps': dict(steps), 'degrees': dict(degrees),
|
||||
'registers': dict(registers),
|
||||
'contexts': {k: {'total': sum(v.values()), 'counts': dict(sorted(v.items()))} for k, v in sorted(tables.items())}}
|
||||
|
||||
+22
-16
@@ -61,27 +61,33 @@ def measures(sources: list[dict], notes: tuple[generator.solo.types.Note, ...],
|
||||
|
||||
def tuplets(measures):
|
||||
# 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
|
||||
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()
|
||||
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]
|
||||
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)
|
||||
|
||||
+17
-19
@@ -6,7 +6,8 @@ import functools
|
||||
|
||||
import generator.fretboard
|
||||
import generator.solo.types
|
||||
from generator.solo.profiles import UNRESTRICTED
|
||||
import generator.solo.profiles
|
||||
import generator.solo.policy
|
||||
|
||||
def travel(previous: generator.solo.types.Note | None, current: generator.solo.types.Note, tempo: fractions.Fraction) -> int:
|
||||
if previous is None:
|
||||
@@ -23,18 +24,15 @@ def transition(previous, current, duration, tempo):
|
||||
def hand_positions(position):
|
||||
return tuple(h for h in range(1, 10) if h <= position.fret <= h + 3)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def position_rank(position):
|
||||
from generator.solo.policy import stable_rank
|
||||
return stable_rank('guitar-position', position)
|
||||
return generator.solo.policy.stable_rank('guitar-position', position)
|
||||
|
||||
def stable_path(path):
|
||||
return tuple(position_rank(n.position) for n in path)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def alternatives(notes, tempo, previous=None, limit=2, profile=UNRESTRICTED):
|
||||
def alternatives(notes, tempo, previous=None, limit=2, profile=generator.solo.profiles.UNRESTRICTED):
|
||||
"""Phrase paths with a four-fret hand region and distinct endpoint choices."""
|
||||
states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)]
|
||||
for note in notes:
|
||||
@@ -49,36 +47,36 @@ def alternatives(notes, tempo, previous=None, limit=2, profile=UNRESTRICTED):
|
||||
continue
|
||||
movement = 0
|
||||
if prior and not note.continuation:
|
||||
seconds = max(.05, float((note.start-prior.start)*60/tempo))
|
||||
distance = abs(hand-old_hand) if old_hand is not None else 0
|
||||
distance += .25*abs(position.fret-prior.position.fret) + .5*abs(position.string_number-prior.position.string_number)
|
||||
movement = round(100*distance/seconds)
|
||||
candidate = (cost+movement, (*path,current), hand)
|
||||
seconds = max(.05, float((note.start - prior.start) * 60 / tempo))
|
||||
distance = abs(hand - old_hand) if old_hand is not None else 0
|
||||
distance += .25 * abs(position.fret - prior.position.fret) + .5 * abs(position.string_number - prior.position.string_number)
|
||||
movement = round(100 * distance / seconds)
|
||||
candidate = (cost + movement, (*path, current), hand)
|
||||
if best is None or candidate[0] < best[0] or candidate[0] == best[0] and stable_path(candidate[1]) < stable_path(best[1]):
|
||||
best = candidate
|
||||
if best is not None:
|
||||
following[position,hand] = best
|
||||
following[position, hand] = best
|
||||
if not following:
|
||||
return ()
|
||||
states = list(following.values())
|
||||
# Endpoints survive so the following phrase can choose a better transition.
|
||||
selected, seen = [], set()
|
||||
for cost,path,hand in sorted(states,key=lambda v:(v[0],stable_path(v[1]))):
|
||||
for cost, path, hand in sorted(states, key=lambda v: (v[0], stable_path(v[1]))):
|
||||
endpoint = path[-1].position if path else None
|
||||
if endpoint in seen:
|
||||
continue
|
||||
seen.add(endpoint); selected.append((cost,path))
|
||||
seen.add(endpoint)
|
||||
selected.append((cost, path))
|
||||
if len(selected) == limit:
|
||||
break
|
||||
return tuple(selected)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def place(notes, tempo, previous=None, profile=UNRESTRICTED):
|
||||
result = alternatives(notes,tempo,previous,1,profile)
|
||||
def place(notes, tempo, previous=None, profile=generator.solo.profiles.UNRESTRICTED):
|
||||
result = alternatives(notes, tempo, previous, 1, profile)
|
||||
return result[0] if result else None
|
||||
|
||||
@functools.lru_cache(maxsize=2048)
|
||||
def positions(number: int, profile=UNRESTRICTED):
|
||||
profile = profile or UNRESTRICTED
|
||||
def positions(number: int, profile=generator.solo.profiles.UNRESTRICTED):
|
||||
profile = profile or generator.solo.profiles.UNRESTRICTED
|
||||
return tuple(p for p in generator.fretboard.single_positions_for_pitch(fractions.Fraction(number)) if p.string_number in profile.strings)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Stable deterministic tie breaking shared with guitar and accompaniment."""
|
||||
import hashlib
|
||||
|
||||
def stable_rank(seed,value):
|
||||
return hashlib.sha256((seed+repr(value)).encode()).hexdigest()
|
||||
def stable_rank(seed, value):
|
||||
return hashlib.sha256((seed + repr(value)).encode()).hexdigest()
|
||||
|
||||
@@ -3,35 +3,31 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
from fractions import Fraction as F
|
||||
import fractions
|
||||
|
||||
import generator.fretboard
|
||||
import generator.vocabulary
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Instrument:
|
||||
part: str = ''
|
||||
strings: tuple[int, ...] = (1, 2, 3, 4, 5, 6)
|
||||
reference: int | None = None
|
||||
melody_range: tuple[int, int] | None = None
|
||||
|
||||
@property
|
||||
def register(self):
|
||||
tuning = [p for s, p in generator.fretboard.STANDARD_TUNING if s in self.strings]
|
||||
return min(tuning) + 1, max(tuning) + 12
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def permits(self, number):
|
||||
return any(p.string_number in self.strings for p in generator.fretboard.single_positions_for_pitch(F(number)))
|
||||
return any(p.string_number in self.strings for p in generator.fretboard.single_positions_for_pitch(fractions.Fraction(number)))
|
||||
|
||||
UNRESTRICTED = Instrument()
|
||||
|
||||
|
||||
def for_part(part, analysis=None):
|
||||
if part not in ('acoustic', 'electric'):
|
||||
raise ValueError('unknown solo instrument: ' + part)
|
||||
strings = (1, 2, 3, 4, 5, 6)
|
||||
if analysis is None or not analysis.melody:
|
||||
return Instrument(part, strings)
|
||||
return Instrument(part,strings)
|
||||
return Instrument(part, strings)
|
||||
|
||||
+133
-107
@@ -4,138 +4,164 @@ No filenames, annotations, learned examples or role exemptions enter here.
|
||||
Existing expression validators prove each candidate; deterministic set-cover
|
||||
search chooses a mutually consistent explanation, never edits the notes.
|
||||
"""
|
||||
from fractions import Fraction as F
|
||||
from generator import vocabulary
|
||||
from generator.solo import expressions, types
|
||||
|
||||
import fractions
|
||||
import generator.vocabulary
|
||||
import generator.solo.expressions
|
||||
import generator.solo.types
|
||||
|
||||
def signature(c):
|
||||
return (c.kind,c.rule,tuple(n.event_id for n in c.events),c.target.event_id,
|
||||
str(c.boundary),c.preparation.event_id if c.preparation else '')
|
||||
|
||||
return (c.kind, c.rule, tuple(n.event_id for n in c.events), c.target.event_id,
|
||||
str(c.boundary), c.preparation.event_id if c.preparation else '')
|
||||
|
||||
def merge(*groups):
|
||||
result={}
|
||||
result = {}
|
||||
for group in groups:
|
||||
for c in group:result.setdefault(signature(c),c)
|
||||
for c in group:
|
||||
result.setdefault(signature(c), c)
|
||||
return tuple(result.values())
|
||||
|
||||
def recognize(notes, harmony, provided=(), *, budget=4096):
|
||||
required = {n.event_id for n in notes if not harmony.ordinary(n)}
|
||||
fixed = {n.event_id for c in provided for n in c.events}
|
||||
if required <= fixed:
|
||||
return tuple(provided), ''
|
||||
candidates = {}
|
||||
single = {}
|
||||
by_id = {n.event_id: n for n in notes}
|
||||
if len(by_id) != len(notes):
|
||||
return (), 'Harmonic analysis requires unique sounding-event IDs'
|
||||
|
||||
def recognize(notes,harmony,provided=(),*,budget=4096):
|
||||
required={n.event_id for n in notes if not harmony.ordinary(n)}
|
||||
fixed={n.event_id for c in provided for n in c.events}
|
||||
if required<=fixed:return tuple(provided),''
|
||||
candidates={};single={};by_id={n.event_id:n for n in notes}
|
||||
if len(by_id)!=len(notes):return (), 'Harmonic analysis requires unique sounding-event IDs'
|
||||
def contract(kind, rule, events, target, boundary=None, preparation=None):
|
||||
span = harmony.at(boundary if boundary is not None else target.start)
|
||||
if not span or span.chord == 'N.C.':
|
||||
return None
|
||||
identifier = f'inferred:{kind}:{rule}:{events[0].event_id}:{target.event_id}:{boundary}'
|
||||
return generator.solo.types.ExpressionContract(identifier, kind, rule, tuple(events), target, span.chord, boundary, preparation)
|
||||
|
||||
def contract(kind,rule,events,target,boundary=None,preparation=None):
|
||||
span=harmony.at(boundary if boundary is not None else target.start)
|
||||
if not span or span.chord=='N.C.':return None
|
||||
identifier=f'inferred:{kind}:{rule}:{events[0].event_id}:{target.event_id}:{boundary}'
|
||||
return types.ExpressionContract(identifier,kind,rule,tuple(events),target,span.chord,boundary,preparation)
|
||||
|
||||
def add(c,extras=()):
|
||||
if c is None:return False
|
||||
bundle=merge(extras,(c,));covered=frozenset(n.event_id for a in bundle for n in a.events)
|
||||
if not (covered & required):return False
|
||||
combined=merge(provided,bundle)
|
||||
_,error=expressions.validate(notes,combined,harmony)
|
||||
if error:return False
|
||||
candidates[tuple(signature(a) for a in bundle)]=(bundle,covered)
|
||||
def add(c, extras=()):
|
||||
if c is None:
|
||||
return False
|
||||
bundle = merge(extras, (c,))
|
||||
covered = frozenset(n.event_id for a in bundle for n in a.events)
|
||||
if not (covered & required):
|
||||
return False
|
||||
combined = merge(provided, bundle)
|
||||
_, error = generator.solo.expressions.validate(notes, combined, harmony)
|
||||
if error:
|
||||
return False
|
||||
candidates[tuple(signature(a) for a in bundle)] = (bundle, covered)
|
||||
return True
|
||||
|
||||
# First discover independently supported colors and harmonic anticipations.
|
||||
# These can also support a later approach's destination or passing endpoint.
|
||||
for i,n in enumerate(notes):
|
||||
if n.event_id not in required:continue
|
||||
future=[t for t in notes[i+1:] if n.end<=t.start<=n.end+F(vocabulary.data('harmony')['resolution_gap'])
|
||||
and 0<abs(t.pitch.number()-n.pitch.number())<=2]
|
||||
regions=[(None,harmony.at(n.start))]
|
||||
regions.extend((s.start,s) for s in harmony.spans if n.start<s.start<n.end
|
||||
and s.start-n.start<=F(vocabulary.data('harmony')['anticipation_max']))
|
||||
for boundary,span in regions:
|
||||
if not span or span.chord=='N.C.':continue
|
||||
for i, n in enumerate(notes):
|
||||
if n.event_id not in required:
|
||||
continue
|
||||
future = [t for t in notes[i + 1:] if n.end <= t.start <= n.end + fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap'])
|
||||
and 0 < abs(t.pitch.number() - n.pitch.number()) <= 2]
|
||||
regions = [(None, harmony.at(n.start))]
|
||||
regions.extend((s.start, s) for s in harmony.spans if n.start < s.start < n.end
|
||||
and s.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']))
|
||||
for boundary, span in regions:
|
||||
if not span or span.chord == 'N.C.':
|
||||
continue
|
||||
if boundary is not None:
|
||||
c=contract('anticipation','early_chord_tone',(n,),n,boundary)
|
||||
if add(c):single.setdefault(n.event_id,[]).append((c,))
|
||||
for rule in vocabulary.data('harmony')['colors']:
|
||||
if not expressions.eligible_color(rule,span,harmony) and boundary is not None:continue
|
||||
kind='anticipated_color' if boundary is not None else 'color'
|
||||
for target in future if rule.get('resolve') else (n,):
|
||||
c=contract(kind,rule['id'],(n,),target,boundary)
|
||||
c = contract('anticipation', 'early_chord_tone', (n,), n, boundary)
|
||||
if add(c):
|
||||
single.setdefault(n.event_id, []).append((c,))
|
||||
for rule in generator.vocabulary.data('harmony')['colors']:
|
||||
if not generator.solo.expressions.eligible_color(rule, span, harmony) and boundary is not None:
|
||||
continue
|
||||
kind = 'anticipated_color' if boundary is not None else 'color'
|
||||
for target in future if rule.get('resolve') else [n]:
|
||||
c = contract(kind, rule['id'], (n,), target, boundary)
|
||||
# Non-anticipated color permission is scoped to the note's initial
|
||||
# harmony, even when its resolution occurs after a chord boundary.
|
||||
if boundary is None and c:c=types.ExpressionContract(c.id,c.kind,c.rule,c.events,c.target,span.chord)
|
||||
if add(c):single.setdefault(n.event_id,[]).append((c,))
|
||||
if i+1<len(notes):
|
||||
target=notes[i+1]
|
||||
if boundary is None and c:
|
||||
c = generator.solo.types.ExpressionContract(c.id, c.kind, c.rule, c.events, c.target, span.chord)
|
||||
if add(c):
|
||||
single.setdefault(n.event_id, []).append((c,))
|
||||
if i + 1 < len(notes):
|
||||
target = notes[i + 1]
|
||||
for s in harmony.spans:
|
||||
if n.start<s.start<n.end:
|
||||
for rule in vocabulary.data('harmony').get('suspensions',[]):
|
||||
add(contract('suspension',rule['id'],(n,),target,s.start))
|
||||
elif s.start==n.end==target.start and n.pitch.number()==target.pitch.number():
|
||||
add(contract('anticipation','early_reattack',(n,),target,s.start))
|
||||
|
||||
for i,target in enumerate(notes):
|
||||
targets=[(None,())]
|
||||
for support in single.get(target.event_id,()):
|
||||
c=support[0]
|
||||
if n.start < s.start < n.end:
|
||||
for rule in generator.vocabulary.data('harmony').get('suspensions', []):
|
||||
add(contract('suspension', rule['id'], (n,), target, s.start))
|
||||
elif s.start == n.end == target.start and n.pitch.number() == target.pitch.number():
|
||||
add(contract('anticipation', 'early_reattack', (n,), target, s.start))
|
||||
for i, target in enumerate(notes):
|
||||
targets = [(None, ())]
|
||||
for support in single.get(target.event_id, ()):
|
||||
c = support[0]
|
||||
# An approach into an anticipated color needs both permissions; that
|
||||
# combination is not silently interpreted as a chord-tone anticipation.
|
||||
if c.kind=='color':targets.append((None,support))
|
||||
elif c.kind=='anticipation':targets.append((c.boundary,support))
|
||||
for rule in vocabulary.data('lead_ins')['patterns']:
|
||||
size=len(rule['semitones'])
|
||||
if i<size:continue
|
||||
ns=notes[i-size:i]
|
||||
if not any(n.event_id in required for n in ns):continue
|
||||
if tuple(n.pitch.number()-target.pitch.number() for n in ns)!=tuple(rule['semitones']):continue
|
||||
preparation=notes[i-size-1] if i>size and rule['kind']=='connection' else None
|
||||
for boundary,extras in targets:
|
||||
add(contract(rule['kind'],rule['id'],ns,target,boundary,preparation),extras)
|
||||
if c.kind == 'color':
|
||||
targets.append((None, support))
|
||||
elif c.kind == 'anticipation':
|
||||
targets.append((c.boundary, support))
|
||||
for rule in generator.vocabulary.data('lead_ins')['patterns']:
|
||||
size = len(rule['semitones'])
|
||||
if i < size:
|
||||
continue
|
||||
ns = notes[i - size:i]
|
||||
if not any(n.event_id in required for n in ns):
|
||||
continue
|
||||
if tuple(n.pitch.number() - target.pitch.number() for n in ns) != tuple(rule['semitones']):
|
||||
continue
|
||||
preparation = notes[i - size - 1] if i > size and rule['kind'] == 'connection' else None
|
||||
for boundary, extras in targets:
|
||||
add(contract(rule['kind'], rule['id'], ns, target, boundary, preparation), extras)
|
||||
# Delayed approaches may include real space or intervening valid music.
|
||||
# They still have an exact semitone target and a finite deadline.
|
||||
rule=vocabulary.data('lead_ins')['delayed_approach']
|
||||
rule = generator.vocabulary.data('lead_ins')['delayed_approach']
|
||||
for n in reversed(notes[:i]):
|
||||
if target.start-n.start>F(rule['deadline']):break
|
||||
if n.event_id in required and target.pitch.number()-n.pitch.number() in rule['steps']:
|
||||
add(contract('delayed_approach',rule['id'],(n,),target))
|
||||
if i>=2:
|
||||
before,n=notes[i-2:i]
|
||||
if target.start - n.start > fractions.Fraction(rule['deadline']):
|
||||
break
|
||||
if n.event_id in required and target.pitch.number() - n.pitch.number() in rule['steps']:
|
||||
add(contract('delayed_approach', rule['id'], (n,), target))
|
||||
if i >= 2:
|
||||
before, n = notes[i - 2:i]
|
||||
if n.event_id in required:
|
||||
for left in [(),*single.get(before.event_id,())]:
|
||||
for right in [(),*single.get(target.event_id,())]:
|
||||
add(contract('passing',vocabulary.data('lead_ins')['passing']['id'],(n,),target,preparation=before),merge(left,right))
|
||||
for rule in vocabulary.data('lead_ins')['blues']:
|
||||
add(contract('blues',rule['id'],(n,),target,preparation=before))
|
||||
|
||||
options={identifier:[] for identifier in sorted(required-fixed,key=lambda identifier:(by_id[identifier].start,identifier))}
|
||||
for bundle,covered in candidates.values():
|
||||
for identifier in covered & options.keys():options[identifier].append((bundle,covered))
|
||||
for identifier,choices in options.items():
|
||||
for left in [(), *single.get(before.event_id, ())]:
|
||||
for right in [(), *single.get(target.event_id, ())]:
|
||||
add(contract('passing', generator.vocabulary.data('lead_ins')['passing']['id'], (n,), target, preparation=before), merge(left, right))
|
||||
for rule in generator.vocabulary.data('lead_ins')['blues']:
|
||||
add(contract('blues', rule['id'], (n,), target, preparation=before))
|
||||
options = {identifier: [] for identifier in sorted(required - fixed, key=lambda identifier: (by_id[identifier].start, identifier))}
|
||||
for bundle, covered in candidates.values():
|
||||
for identifier in covered & options.keys():
|
||||
options[identifier].append((bundle, covered))
|
||||
for identifier, choices in options.items():
|
||||
if not choices:
|
||||
n=by_id[identifier]
|
||||
return (),f'{identifier} at {n.start}: unsupported harmonic relationship for {n.pitch.label()}'
|
||||
choices.sort(key=lambda x:(sum((n.end-n.start for c in x[0] for n in c.events),F()),
|
||||
sum((max(F(),c.target.start-c.events[-1].end) for c in x[0]),F()),tuple(signature(c) for c in x[0])))
|
||||
n = by_id[identifier]
|
||||
return (), f'{identifier} at {n.start}: unsupported harmonic relationship for {n.pitch.label()}'
|
||||
choices.sort(key=lambda x: (sum((n.end - n.start for c in x[0] for n in c.events), fractions.Fraction()),
|
||||
sum((max(fractions.Fraction(), c.target.start - c.events[-1].end) for c in x[0]), fractions.Fraction()), tuple(signature(c) for c in x[0])))
|
||||
visits = 0
|
||||
failed = set()
|
||||
|
||||
visits=0;failed=set()
|
||||
def solve(chosen,covered):
|
||||
def solve(chosen, covered):
|
||||
nonlocal visits
|
||||
remaining=required-covered
|
||||
if not remaining:return chosen
|
||||
key=tuple(sorted(signature(c) for c in chosen))
|
||||
if key in failed or visits>=budget:return None
|
||||
visits+=1
|
||||
identifier=min(remaining,key=lambda x:(len(options[x]),by_id[x].start,x))
|
||||
for bundle,added in options[identifier]:
|
||||
combined=merge(chosen,bundle)
|
||||
_,error=expressions.validate(notes,combined,harmony)
|
||||
if error:continue
|
||||
result=solve(combined,covered|added)
|
||||
if result is not None:return result
|
||||
remaining = required - covered
|
||||
if not remaining:
|
||||
return chosen
|
||||
key = tuple(sorted(signature(c) for c in chosen))
|
||||
if key in failed or visits >= budget:
|
||||
return None
|
||||
visits += 1
|
||||
identifier = min(remaining, key=lambda x: (len(options[x]), by_id[x].start, x))
|
||||
for bundle, added in options[identifier]:
|
||||
combined = merge(chosen, bundle)
|
||||
_, error = generator.solo.expressions.validate(notes, combined, harmony)
|
||||
if error:
|
||||
continue
|
||||
result = solve(combined, covered | added)
|
||||
if result is not None:
|
||||
return result
|
||||
failed.add(key)
|
||||
return None
|
||||
result=solve(tuple(provided),fixed)
|
||||
if result is None:return (), 'Harmonic treatment search budget exhausted' if visits>=budget else 'No mutually consistent harmonic treatment coverage'
|
||||
return result,''
|
||||
|
||||
result = solve(tuple(provided), fixed)
|
||||
if result is None:
|
||||
return (), 'Harmonic treatment search budget exhausted' if visits >= budget else 'No mutually consistent harmonic treatment coverage'
|
||||
return result, ''
|
||||
|
||||
+55
-44
@@ -1,60 +1,71 @@
|
||||
"""Compact reports of actual selected phrases, not duplicated draft ancestry."""
|
||||
import dataclasses, fractions, json
|
||||
import generator.solo.placement
|
||||
from generator.solo import harmonic
|
||||
import generator.solo.harmonic
|
||||
import collections
|
||||
import generator.fretboard
|
||||
|
||||
def plain(value):
|
||||
if dataclasses.is_dataclass(value):return plain(dataclasses.asdict(value))
|
||||
if isinstance(value,dict):return {k:plain(v) for k,v in value.items()}
|
||||
if isinstance(value,(tuple,list,set,frozenset)):return [plain(v) for v in value]
|
||||
if isinstance(value,fractions.Fraction):return str(value)
|
||||
if dataclasses.is_dataclass(value):
|
||||
return plain(dataclasses.asdict(value))
|
||||
if isinstance(value, dict):
|
||||
return {k: plain(v) for k, v in value.items()}
|
||||
if isinstance(value, (tuple, list, set, frozenset)):
|
||||
return [plain(v) for v in value]
|
||||
if isinstance(value, fractions.Fraction):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
def structure(solo):
|
||||
harmony=harmonic.Harmony(solo.harmony)
|
||||
contracts=tuple(c for p in solo.phrases for c in p.expressions)
|
||||
checked=harmony.evaluate(solo.notes(),until=solo.end,complete=True,expressions=contracts)
|
||||
phrases=[]
|
||||
harmony = generator.solo.harmonic.Harmony(solo.harmony)
|
||||
contracts = tuple(c for p in solo.phrases for c in p.expressions)
|
||||
checked = harmony.evaluate(solo.notes(), until=solo.end, complete=True, expressions=contracts)
|
||||
phrases = []
|
||||
for p in solo.phrases:
|
||||
entry={'id':p.id,'start':str(p.start),'end':str(p.end),'purpose':p.purpose,'direction':p.direction,'ending':p.ending,
|
||||
'theme':p.theme,'notes':plain(p.notes),'rhythm_groups':plain(p.groups),'sources':p.sources,
|
||||
'identifying_events':plain(p.identity),
|
||||
'evaluation':plain(p.evaluation),'score':p.score,'expressions':plain(p.expressions)}
|
||||
entry = {'id': p.id, 'start': str(p.start), 'end': str(p.end), 'purpose': p.purpose, 'direction': p.direction, 'ending': p.ending,
|
||||
'theme': p.theme, 'notes': plain(p.notes), 'rhythm_groups': plain(p.groups), 'sources': p.sources,
|
||||
'identifying_events': plain(p.identity),
|
||||
'evaluation': plain(p.evaluation), 'score': p.score, 'expressions': plain(p.expressions)}
|
||||
phrases.append(entry)
|
||||
ns=solo.notes()
|
||||
duration=solo.end-solo.start
|
||||
return {'schema':'composer-report-3','selection_trace':plain({k:v for k,v in solo.diagnostics.items() if not k.endswith('_seconds')}),
|
||||
'instrument_profile':plain(solo.profile),'phrases':phrases,'expressions':plain(contracts),'fretboard':fretboard_report(solo),
|
||||
'phrasing':{'attacks':len(ns),'allocation_beats':str(duration),
|
||||
'attacks_per_four_beats':4*len(ns)/float(duration) if duration else 0,
|
||||
'occupancy':sum(float(n.end-n.start) for n in ns)/float(duration) if duration else 0},
|
||||
'harmonic_roles':harmony.describe(ns,expressions=contracts),'harmonic_policy':'contextual (shared by authored and predicted music)', 'harmonic_valid':checked.valid,'harmonic_error':checked.error,
|
||||
'harmonic_exposure':checked.exposure,'unresolved_treatments':plain(checked.pending)}
|
||||
ns = solo.notes()
|
||||
duration = solo.end - solo.start
|
||||
return {'schema': 'composer-report-3', 'selection_trace': plain({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}),
|
||||
'instrument_profile': plain(solo.profile), 'phrases': phrases, 'expressions': plain(contracts), 'fretboard': fretboard_report(solo),
|
||||
'phrasing': {'attacks': len(ns), 'allocation_beats': str(duration),
|
||||
'attacks_per_four_beats': 4 * len(ns) / float(duration) if duration else 0,
|
||||
'occupancy': sum(float(n.end - n.start) for n in ns) / float(duration) if duration else 0},
|
||||
'harmonic_roles': harmony.describe(ns, expressions=contracts), 'harmonic_policy': 'contextual (shared by authored and predicted music)', 'harmonic_valid': checked.valid, 'harmonic_error': checked.error,
|
||||
'harmonic_exposure': checked.exposure, 'unresolved_treatments': plain(checked.pending)}
|
||||
|
||||
def describe(solo,measure_offset=0,time_offset=0,*,detail=None):
|
||||
detail=detail or structure(solo)
|
||||
lines=[f'{solo.diagnostics.get("construction","solo")}: {solo.part}', 'Times are exact quarter beats from the chorus start; negative times are allocated lead-ins. Pitches sound one octave below guitar notation.',
|
||||
'Approximate fingering costs are not a biomechanical guarantee.',json.dumps(detail['phrasing'])]
|
||||
def describe(solo, measure_offset=0, time_offset=0, *, detail=None):
|
||||
detail = detail or structure(solo)
|
||||
lines = [f'{solo.diagnostics.get("construction", "solo")}: {solo.part}', 'Times are exact quarter beats from the chorus start; negative times are allocated lead-ins. Pitches sound one octave below guitar notation.',
|
||||
'Approximate fingering costs are not a biomechanical guarantee.', json.dumps(detail['phrasing'])]
|
||||
for p in solo.phrases:
|
||||
lines.append(f'{p.id}: {p.start}–{p.end}; {p.purpose}, {p.direction}, {p.ending}; theme {p.theme}; score {p.score:.4f}')
|
||||
for n in p.notes:lines.append(f' {n.start}–{n.end}: {n.pitch.label()}, {n.role}; string {n.position.string_number}, fret {n.position.fret}')
|
||||
for c in p.expressions:lines.append(f' {c.kind}/{c.rule} → {c.target.pitch.label()} at {c.target.start}, held to {c.target.end}')
|
||||
return '\n'.join(lines)+'\n'
|
||||
for n in p.notes:
|
||||
lines.append(f' {n.start}–{n.end}: {n.pitch.label()}, {n.role}; string {n.position.string_number}, fret {n.position.fret}')
|
||||
for c in p.expressions:
|
||||
lines.append(f' {c.kind}/{c.rule} → {c.target.pitch.label()} at {c.target.start}, held to {c.target.end}')
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
def fretboard_report(solo):
|
||||
import collections
|
||||
import generator.fretboard
|
||||
notes=solo.notes()
|
||||
attacks=collections.Counter(); durations=collections.Counter(); forced=optional=0
|
||||
notes = solo.notes()
|
||||
attacks = collections.Counter()
|
||||
durations = collections.Counter()
|
||||
forced = optional = 0
|
||||
for note in notes:
|
||||
string=note.position.string_number
|
||||
attacks[string]+=1;durations[string]+=float(note.end-note.start)
|
||||
if string==1:
|
||||
if len(generator.solo.placement.positions(note.pitch.number(),solo.profile))==1:forced+=1
|
||||
else:optional+=1
|
||||
total=sum(durations.values())
|
||||
return {'attacks_by_string':dict(sorted(attacks.items())),
|
||||
'duration_fraction_by_string':{s:d/max(.001,total) for s,d in sorted(durations.items())},
|
||||
'first_string_forced':forced,'first_string_optional':optional,
|
||||
'range_midi': [min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)] if notes else [],
|
||||
'cross_bar_sounding_events':sum(any(n.start < t < n.end for t in {c.start for c in solo.harmony if c.beat == 1}) for n in notes)}
|
||||
string = note.position.string_number
|
||||
attacks[string] += 1
|
||||
durations[string] += float(note.end - note.start)
|
||||
if string == 1:
|
||||
if len(generator.solo.placement.positions(note.pitch.number(), solo.profile)) == 1:
|
||||
forced += 1
|
||||
else:
|
||||
optional += 1
|
||||
total = sum(durations.values())
|
||||
return {'attacks_by_string': dict(sorted(attacks.items())),
|
||||
'duration_fraction_by_string': {s: d / max(.001, total) for s, d in sorted(durations.items())},
|
||||
'first_string_forced': forced, 'first_string_optional': optional,
|
||||
'range_midi': [min(n.pitch.number() for n in notes), max(n.pitch.number() for n in notes)] if notes else [],
|
||||
'cross_bar_sounding_events': sum(any(n.start < t < n.end for t in {c.start for c in solo.harmony if c.beat == 1}) for n in notes)}
|
||||
|
||||
@@ -5,7 +5,6 @@ import generator.model
|
||||
import generator.fretboard
|
||||
import generator.solo.profiles
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True, order=True)
|
||||
class Pitch:
|
||||
letter: str
|
||||
|
||||
+167
-120
@@ -6,151 +6,198 @@ import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pathlib
|
||||
import tempfile
|
||||
import time
|
||||
from fractions import Fraction as F
|
||||
from generator import harmony, model, musicxml, parts, training_io, vocabulary
|
||||
from generator import harmonic_time as timeline
|
||||
from generator.solo import analysis, learned
|
||||
|
||||
import fractions
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.training_io
|
||||
import generator.vocabulary
|
||||
import generator.harmonic_time
|
||||
import generator.solo.analysis
|
||||
import generator.solo.learned
|
||||
import shutil
|
||||
import generator.production
|
||||
import generator.solo.authored
|
||||
import generator.fretboard
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Span:
|
||||
start:F
|
||||
end:F
|
||||
chord:str
|
||||
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
chord: str
|
||||
|
||||
class Context:
|
||||
def __init__(self,document):
|
||||
self.spans=tuple(Span(s.start,s.end,harmony.symbol(s.chord) if s.chord else 'N.C.')
|
||||
for s in timeline.read(document['staves'][0]))
|
||||
def at(self,time):return next((s for s in self.spans if s.start<=time<s.end),None)
|
||||
|
||||
def __init__(self, document):
|
||||
self.spans = tuple(Span(s.start, s.end, generator.harmony.symbol(s.chord) if s.chord else 'N.C.')
|
||||
for s in generator.harmonic_time.read(document['staves'][0]))
|
||||
def at(self, time): return next((s for s in self.spans if s.start <= time < s.end), None)
|
||||
|
||||
def load_examples(manifest):
|
||||
manifest=Path(manifest);value=json.loads(manifest.read_text())
|
||||
if value.get('version')!=1:raise ValueError('Unsupported training manifest')
|
||||
examples=[];provenance=[];seen=set();pairs=set()
|
||||
for entry in sorted(value['examples'],key=lambda e:e['id']):
|
||||
if entry['id'] in seen or entry['slot'] not in (1,2):raise ValueError('Duplicate example or invalid role')
|
||||
seen.add(entry['id']);role='acoustic' if entry['slot']==1 else 'electric'
|
||||
source=(manifest.parent/entry['source']).resolve();solo=(manifest.parent/entry['solo']).resolve()
|
||||
if not solo.is_relative_to(manifest.parent.resolve()):raise ValueError('Training solo must be inside the training directory')
|
||||
if not source.is_relative_to((manifest.parent.parent/'songs').resolve()):raise ValueError('Training sources must be inside data/songs')
|
||||
document=parts.prepare_document(musicxml.load_musicxml(source));a=analysis.analyze(document)
|
||||
from generator.production import SourceAnalysis
|
||||
pair=(SourceAnalysis.read(document).musical_key,entry['slot'])
|
||||
if pair in pairs:raise ValueError('Duplicate source music and solo slot')
|
||||
manifest = pathlib.Path(manifest)
|
||||
value = json.loads(manifest.read_text())
|
||||
if value.get('version') != 1:
|
||||
raise ValueError('Unsupported training manifest')
|
||||
examples = []
|
||||
provenance = []
|
||||
seen = set()
|
||||
pairs = set()
|
||||
for entry in sorted(value['examples'], key=lambda e: e['id']):
|
||||
if entry['id'] in seen or entry['slot'] not in (1, 2):
|
||||
raise ValueError('Duplicate example or invalid role')
|
||||
seen.add(entry['id'])
|
||||
role = 'acoustic' if entry['slot'] == 1 else 'electric'
|
||||
source = (manifest.parent / entry['source']).resolve()
|
||||
solo = (manifest.parent / entry['solo']).resolve()
|
||||
if not solo.is_relative_to(manifest.parent.resolve()):
|
||||
raise ValueError('Training solo must be inside the training directory')
|
||||
if not source.is_relative_to((manifest.parent.parent / 'songs').resolve()):
|
||||
raise ValueError('Training sources must be inside data/songs')
|
||||
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(source))
|
||||
a = generator.solo.analysis.analyze(document)
|
||||
pair = (generator.production.SourceAnalysis.read(document).musical_key, entry['slot'])
|
||||
if pair in pairs:
|
||||
raise ValueError('Duplicate source music and solo slot')
|
||||
pairs.add(pair)
|
||||
evidence=training_io.read(solo,role,pitch_convention=entry['pitch_convention'])
|
||||
spans=[(F(m['start']),F(m['duration'])) for m in evidence['measures']]
|
||||
if spans!=[(x,y-x) for x,y in zip(a.boundaries,a.boundaries[1:])]:raise ValueError(entry['id']+': source/solo measure timing differs; no padding or pickup removal allowed')
|
||||
if evidence['discrepancies']:raise ValueError(entry['id']+': '+str(evidence['discrepancies']))
|
||||
ctx=Context(document)
|
||||
training_io.validate_harmony(document,evidence,entry['id'])
|
||||
from generator.solo.authored import validate_evidence
|
||||
checked=validate_evidence(document,evidence)
|
||||
events=[];cursor=F()
|
||||
from generator import fretboard
|
||||
evidence = generator.training_io.read(solo, role, pitch_convention=entry['pitch_convention'])
|
||||
spans = [(fractions.Fraction(m['start']), fractions.Fraction(m['duration'])) for m in evidence['measures']]
|
||||
if spans != [(x, y - x) for x, y in zip(a.boundaries, a.boundaries[1:])]:
|
||||
raise ValueError(entry['id'] + ': source/solo measure timing differs; no padding or pickup removal allowed')
|
||||
if evidence['discrepancies']:
|
||||
raise ValueError(entry['id'] + ': ' + str(evidence['discrepancies']))
|
||||
ctx = Context(document)
|
||||
generator.training_io.validate_harmony(document, evidence, entry['id'])
|
||||
checked = generator.solo.authored.validate_evidence(document, evidence)
|
||||
events = []
|
||||
cursor = fractions.Fraction()
|
||||
for n in evidence['sounding_events']:
|
||||
start,end=F(n['start']),F(n['end']);number=F(n['sounding_midi'])
|
||||
if number.denominator!=1:raise ValueError(entry['id']+': microtonal guitar pitch is unsupported')
|
||||
pitch=int(number)
|
||||
if not fretboard.single_positions_for_pitch(F(pitch)):raise ValueError(entry['id']+': unplayable sounding pitch '+str(pitch)+' at '+str(start))
|
||||
if start<cursor or end<=start:raise ValueError(entry['id']+': non-monophonic/invalid event')
|
||||
if start>cursor:events.append((cursor,start,None))
|
||||
events.append((start,end,pitch));cursor=end
|
||||
if cursor<a.boundaries[-1]:events.append((cursor,a.boundaries[-1],None))
|
||||
source_hash=hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
examples.append({'id':entry['id'],'song':source_hash,'role':role,'analysis':a,'harmony':ctx,'events':tuple(events),
|
||||
'harmonic_validation':{'valid':checked.valid,'treatments':dict(sorted(collections.Counter(c.rule for c in checked.contracts).items()))}})
|
||||
provenance.append({'id':entry['id'],'source_sha256':source_hash,'solo_sha256':evidence['sha256'],
|
||||
'pitch_convention':entry['pitch_convention'],'measures':len(spans),'pickup_beats':str(a.body_offset),
|
||||
'slot':entry['slot'],'attacks':len(evidence['sounding_events']),'annotations':len(evidence['annotations']),
|
||||
'adapter_notes':['Trailing chord-display parentheses interpreted without modifying XML.'] if any(h['display'] and h['display'].endswith(')') and '(' not in h['display'] for h in evidence['harmonies']) else []})
|
||||
if not examples:raise ValueError('No training examples')
|
||||
return examples,{'description':value['provenance'],'examples':provenance,'training_version':learned.VERSION}
|
||||
start, end = fractions.Fraction(n['start']), fractions.Fraction(n['end'])
|
||||
number = fractions.Fraction(n['sounding_midi'])
|
||||
if number.denominator != 1:
|
||||
raise ValueError(entry['id'] + ': microtonal guitar pitch is unsupported')
|
||||
pitch = int(number)
|
||||
if not generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch)):
|
||||
raise ValueError(entry['id'] + ': unplayable sounding pitch ' + str(pitch) + ' at ' + str(start))
|
||||
if start < cursor or end <= start:
|
||||
raise ValueError(entry['id'] + ': non-monophonic/invalid event')
|
||||
if start > cursor:
|
||||
events.append((cursor, start, None))
|
||||
events.append((start, end, pitch))
|
||||
cursor = end
|
||||
if cursor < a.boundaries[-1]:
|
||||
events.append((cursor, a.boundaries[-1], None))
|
||||
source_hash = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
examples.append({'id': entry['id'], 'song': source_hash, 'role': role, 'analysis': a, 'harmony': ctx, 'events': tuple(events),
|
||||
'harmonic_validation': {'valid': checked.valid, 'treatments': dict(sorted(collections.Counter(c.rule for c in checked.contracts).items()))}})
|
||||
provenance.append({'id': entry['id'], 'source_sha256': source_hash, 'solo_sha256': evidence['sha256'],
|
||||
'pitch_convention': entry['pitch_convention'], 'measures': len(spans), 'pickup_beats': str(a.body_offset),
|
||||
'slot': entry['slot'], 'attacks': len(evidence['sounding_events']), 'annotations': len(evidence['annotations']),
|
||||
'adapter_notes': ['Trailing chord-display parentheses interpreted without modifying XML.'] if any(h['display'] and h['display'].endswith(')') and '(' not in h['display'] for h in evidence['harmonies']) else []})
|
||||
if not examples:
|
||||
raise ValueError('No training examples')
|
||||
return examples, {'description': value['provenance'], 'examples': provenance, 'training_version': generator.solo.learned.VERSION}
|
||||
|
||||
def likelihood(predictor, example):
|
||||
history = []
|
||||
previous = None
|
||||
cost = 0
|
||||
for start, end, pitch in example['events']:
|
||||
harmonic, root, _ = generator.solo.learned.chord_context(example['harmony'], start)
|
||||
action = generator.solo.learned.token(end - start, pitch, previous, root)
|
||||
relative = start - example['analysis'].body_offset
|
||||
c = generator.solo.learned.contexts(harmonic, generator.solo.learned.phase_at(example['analysis'], relative), generator.solo.learned.source_feature(example['analysis'], relative, root), tuple(history[-4:]))
|
||||
cost += predictor.cost(action, c)
|
||||
history.append(action)
|
||||
if pitch is not None:
|
||||
previous = pitch
|
||||
return cost / len(history)
|
||||
|
||||
def likelihood(predictor,example):
|
||||
history=[];previous=None;cost=0
|
||||
for start,end,pitch in example['events']:
|
||||
harmonic,root,_=learned.chord_context(example['harmony'],start)
|
||||
action=learned.token(end-start,pitch,previous,root)
|
||||
relative=start-example['analysis'].body_offset
|
||||
c=learned.contexts(harmonic,learned.phase_at(example['analysis'],relative),learned.source_feature(example['analysis'],relative,root),tuple(history[-4:]))
|
||||
cost+=predictor.cost(action,c);history.append(action)
|
||||
if pitch is not None:previous=pitch
|
||||
return cost/len(history)
|
||||
def atomic_json(path, value):
|
||||
path = pathlib.Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile('w', dir=path.parent, delete=False) as stream:
|
||||
json.dump(value, stream, sort_keys=True, separators=(',', ':'))
|
||||
stream.write('\n')
|
||||
name = stream.name
|
||||
os.chmod(name, 0o644)
|
||||
os.replace(name, path)
|
||||
|
||||
|
||||
def atomic_json(path,value):
|
||||
path=Path(path);path.parent.mkdir(parents=True,exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile('w',dir=path.parent,delete=False) as stream:
|
||||
json.dump(value,stream,sort_keys=True,separators=(',',':'));stream.write('\n');name=stream.name
|
||||
os.chmod(name,0o644)
|
||||
os.replace(name,path)
|
||||
|
||||
|
||||
def register_example(manifest,source,solo,slot,pitch_convention):
|
||||
def register_example(manifest, source, solo, slot, pitch_convention):
|
||||
"""Copy and validate a new pair before atomically publishing its manifest."""
|
||||
import shutil
|
||||
manifest=Path(manifest).resolve();root=manifest.parent
|
||||
sources=root.parent/'songs';root.mkdir(parents=True,exist_ok=True);sources.mkdir(parents=True,exist_ok=True)
|
||||
value=json.loads(manifest.read_text()) if manifest.exists() else {'version':1,'provenance':'User-authored paired song/solo training corpus.','examples':[]}
|
||||
source,solo=Path(source).resolve(),Path(solo).resolve()
|
||||
destinations=((source,sources/source.name),(solo,root/solo.name));created=[];temporary=None
|
||||
manifest = pathlib.Path(manifest).resolve()
|
||||
root = manifest.parent
|
||||
sources = root.parent / 'songs'
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
sources.mkdir(parents=True, exist_ok=True)
|
||||
value = json.loads(manifest.read_text()) if manifest.exists() else {'version': 1, 'provenance': 'User-authored paired song/solo training corpus.', 'examples': []}
|
||||
source, solo = pathlib.Path(source).resolve(), pathlib.Path(solo).resolve()
|
||||
destinations = ((source, sources / source.name), (solo, root / solo.name))
|
||||
created = []
|
||||
temporary = None
|
||||
try:
|
||||
for original,destination in destinations:
|
||||
for original, destination in destinations:
|
||||
if destination.exists():
|
||||
if original.read_bytes()!=destination.read_bytes():raise ValueError(str(destination)+': already exists with different contents; no overwrite performed')
|
||||
else:shutil.copyfile(original,destination);created.append(destination)
|
||||
entry={'id':source.stem+'/solo'+str(slot),'source':'../songs/'+source.name,'solo':solo.name,
|
||||
'slot':slot,'pitch_convention':pitch_convention}
|
||||
if any(e['id']==entry['id'] for e in value['examples']):raise ValueError('Example already registered: '+entry['id'])
|
||||
if original.read_bytes() != destination.read_bytes():
|
||||
raise ValueError(str(destination) + ': already exists with different contents; no overwrite performed')
|
||||
else:
|
||||
shutil.copyfile(original, destination)
|
||||
created.append(destination)
|
||||
entry = {'id': source.stem + '/solo' + str(slot), 'source': '../songs/' + source.name, 'solo': solo.name,
|
||||
'slot': slot, 'pitch_convention': pitch_convention}
|
||||
if any(e['id'] == entry['id'] for e in value['examples']):
|
||||
raise ValueError('Example already registered: ' + entry['id'])
|
||||
value['examples'].append(entry)
|
||||
with tempfile.NamedTemporaryFile('w',dir=root,suffix='.json',delete=False) as stream:
|
||||
json.dump(value,stream);temporary=Path(stream.name)
|
||||
with tempfile.NamedTemporaryFile('w', dir=root, suffix='.json', delete=False) as stream:
|
||||
json.dump(value, stream)
|
||||
temporary = pathlib.Path(stream.name)
|
||||
load_examples(temporary)
|
||||
atomic_json(manifest,value)
|
||||
atomic_json(manifest, value)
|
||||
except BaseException:
|
||||
for p in created:p.unlink()
|
||||
for p in created:
|
||||
p.unlink()
|
||||
raise
|
||||
finally:
|
||||
if temporary:temporary.unlink(missing_ok=True)
|
||||
|
||||
if temporary:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest',type=Path,default=Path('data/training/manifest.json'))
|
||||
parser.add_argument('--output',type=Path,default=Path('data/models/solo.json'))
|
||||
parser.add_argument('--report',type=Path,default=Path('build/training/report.json'))
|
||||
parser.add_argument('--leave-one-song-out',action='store_true')
|
||||
parser.add_argument('--add-source',type=Path,help='Register a source song (copied to data/songs if needed).')
|
||||
parser.add_argument('--add-solo',type=Path,help='Register its authored solo (copied unchanged to data/training).')
|
||||
parser.add_argument('--slot',type=int,choices=(1,2),help='Arrangement mapping only: 1 acoustic, 2 electric; learning is pooled.')
|
||||
parser.add_argument('--pitch-convention',choices=('guitar-written','encoded'),default='guitar-written')
|
||||
args=parser.parse_args();began=time.monotonic()
|
||||
if any((args.add_source,args.add_solo,args.slot)):
|
||||
if not all((args.add_source,args.add_solo,args.slot)):parser.error('--add-source, --add-solo and --slot must be supplied together')
|
||||
register_example(args.manifest,args.add_source,args.add_solo,args.slot,args.pitch_convention)
|
||||
examples,provenance=load_examples(args.manifest)
|
||||
value=learned.fit(examples,provenance);predictor=learned.Model(value)
|
||||
report={'version':learned.VERSION,'training':provenance,'model_sha256':vocabulary.fingerprint(value),
|
||||
'contexts':len(value['contexts']),'in_sample_event_nll':{e['id']:likelihood(predictor,e) for e in examples},
|
||||
'harmonic_validation':{e['id']:e['harmonic_validation'] for e in examples},
|
||||
'limitations':'A small corpus does not establish generalization. Likelihood is teacher-forced, not free generation or musical preference.'}
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest', type=pathlib.Path, default=pathlib.Path('data/training/manifest.json'))
|
||||
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('data/models/solo.json'))
|
||||
parser.add_argument('--report', type=pathlib.Path, default=pathlib.Path('build/training/report.json'))
|
||||
parser.add_argument('--leave-one-song-out', action='store_true')
|
||||
parser.add_argument('--add-source', type=pathlib.Path, help='Register a source song (copied to data/songs if needed).')
|
||||
parser.add_argument('--add-solo', type=pathlib.Path, help='Register its authored solo (copied unchanged to data/training).')
|
||||
parser.add_argument('--slot', type=int, choices=(1, 2), help='Arrangement mapping only: 1 acoustic, 2 electric; learning is pooled.')
|
||||
parser.add_argument('--pitch-convention', choices=('guitar-written', 'encoded'), default='guitar-written')
|
||||
args = parser.parse_args()
|
||||
began = time.monotonic()
|
||||
if any((args.add_source, args.add_solo, args.slot)):
|
||||
if not all((args.add_source, args.add_solo, args.slot)):
|
||||
parser.error('--add-source, --add-solo and --slot must be supplied together')
|
||||
register_example(args.manifest, args.add_source, args.add_solo, args.slot, args.pitch_convention)
|
||||
examples, provenance = load_examples(args.manifest)
|
||||
value = generator.solo.learned.fit(examples, provenance)
|
||||
predictor = generator.solo.learned.Model(value)
|
||||
report = {'version': generator.solo.learned.VERSION, 'training': provenance, 'model_sha256': generator.vocabulary.fingerprint(value),
|
||||
'contexts': len(value['contexts']), 'in_sample_event_nll': {e['id']: likelihood(predictor, e) for e in examples},
|
||||
'harmonic_validation': {e['id']: e['harmonic_validation'] for e in examples},
|
||||
'limitations': 'A small corpus does not establish generalization. Likelihood is teacher-forced, not free generation or musical preference.'}
|
||||
if args.leave_one_song_out:
|
||||
report['held_out_event_nll']={}
|
||||
report['held_out_event_nll'] = {}
|
||||
for song in sorted({e['song'] for e in examples}):
|
||||
remaining=[e for e in examples if e['song']!=song]
|
||||
if not remaining:continue
|
||||
fitted=learned.Model(learned.fit(remaining,{'evaluation_only':True}))
|
||||
report['held_out_event_nll'].update({e['id']:likelihood(fitted,e) for e in examples if e['song']==song})
|
||||
atomic_json(args.output,value);report['training_seconds']=time.monotonic()-began;atomic_json(args.report,report)
|
||||
remaining = [e for e in examples if e['song'] != song]
|
||||
if not remaining:
|
||||
continue
|
||||
fitted = generator.solo.learned.Model(generator.solo.learned.fit(remaining, {'evaluation_only': True}))
|
||||
report['held_out_event_nll'].update({e['id']: likelihood(fitted, e) for e in examples if e['song'] == song})
|
||||
atomic_json(args.output, value)
|
||||
report['training_seconds'] = time.monotonic() - began
|
||||
atomic_json(args.report, report)
|
||||
print(f'Trained {len(examples)} solos: {len(value["contexts"])} contexts in {report["training_seconds"]:.3f}s; {args.output}')
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
+160
-119
@@ -1,152 +1,193 @@
|
||||
"""Literal authored-score reader for training, registered repertoire and comparison."""
|
||||
from __future__ import annotations
|
||||
import hashlib,json
|
||||
from pathlib import Path
|
||||
from fractions import Fraction as F
|
||||
import xml.etree.ElementTree as ET
|
||||
import hashlib, json
|
||||
import pathlib
|
||||
import fractions
|
||||
import xml.etree.ElementTree
|
||||
import generator.fretboard
|
||||
|
||||
def read(path, role, form_start=None, pitch_convention='encoded'):
|
||||
if pitch_convention not in ('encoded','guitar-written'):
|
||||
raise ValueError('Unknown reference pitch convention: '+pitch_convention)
|
||||
raw=Path(path).read_bytes();tree=ET.fromstring(raw)
|
||||
result={'path':str(Path(path).resolve()),'sha256':hashlib.sha256(raw).hexdigest(),'validation_role':role,
|
||||
'pitch_convention':pitch_convention,'part_names':[n.text for n in tree.findall('./part-list/score-part/part-name')],
|
||||
'measures':[],'notes':[],'harmonies':[],'annotations':[],'discrepancies':[]}
|
||||
divisions=F(1);absolute=F();nominal=F(4);pending={};active=None;transpose=F()
|
||||
for index,m in enumerate(tree.findall('./part/measure')):
|
||||
cursor=F();extent=F();last_start=F();last_duration=F()
|
||||
if (value:=m.findtext('attributes/divisions')) is not None:divisions=F(value)
|
||||
meter=m.find('attributes/time')
|
||||
if pitch_convention not in ('encoded', 'guitar-written'):
|
||||
raise ValueError('Unknown reference pitch convention: ' + pitch_convention)
|
||||
raw = pathlib.Path(path).read_bytes()
|
||||
tree = xml.etree.ElementTree.fromstring(raw)
|
||||
result = {'path': str(pathlib.Path(path).resolve()), 'sha256': hashlib.sha256(raw).hexdigest(), 'validation_role': role,
|
||||
'pitch_convention': pitch_convention, 'part_names': [n.text for n in tree.findall('./part-list/score-part/part-name')],
|
||||
'measures': [], 'notes': [], 'harmonies': [], 'annotations': [], 'discrepancies': []}
|
||||
divisions = fractions.Fraction(1)
|
||||
absolute = fractions.Fraction()
|
||||
nominal = fractions.Fraction(4)
|
||||
pending = {}
|
||||
active = None
|
||||
transpose = fractions.Fraction()
|
||||
for index, m in enumerate(tree.findall('./part/measure')):
|
||||
cursor = fractions.Fraction()
|
||||
extent = fractions.Fraction()
|
||||
last_start = fractions.Fraction()
|
||||
last_duration = fractions.Fraction()
|
||||
if (value := m.findtext('attributes/divisions')) is not None:
|
||||
divisions = fractions.Fraction(value)
|
||||
meter = m.find('attributes/time')
|
||||
if meter is not None and meter.findtext('beats'):
|
||||
nominal=sum((F(v) for v in meter.findtext('beats').split('+')),F())*4/F(meter.findtext('beat-type'))
|
||||
nominal = sum((fractions.Fraction(v) for v in meter.findtext('beats').split('+')), fractions.Fraction()) * 4 / fractions.Fraction(meter.findtext('beat-type'))
|
||||
for item in m:
|
||||
offset=F(item.findtext('offset','0'))/divisions
|
||||
if item.tag=='attributes' and item.find('transpose') is not None:
|
||||
transpose=F(item.findtext('transpose/chromatic','0'))+12*F(item.findtext('transpose/octave-change','0'))
|
||||
elif item.tag=='backup':cursor-=F(item.findtext('duration'))/divisions
|
||||
elif item.tag=='forward':cursor+=F(item.findtext('duration'))/divisions;extent=max(extent,cursor)
|
||||
elif item.tag=='harmony':
|
||||
active={'time':str(absolute+cursor+offset),'measure':index+1,'offset':str(cursor+offset),
|
||||
'root':item.findtext('root/root-step'),'alter':item.findtext('root/root-alter','0'),
|
||||
'kind':item.findtext('kind'),'display':item.find('kind').get('text') if item.find('kind') is not None else None,
|
||||
'bass':ET.tostring(item.find('bass'),encoding='unicode') if item.find('bass') is not None else None,
|
||||
'degrees':[{'value':d.findtext('degree-value'),'alter':d.findtext('degree-alter'),'type':d.findtext('degree-type')} for d in item.findall('degree')]}
|
||||
offset = fractions.Fraction(item.findtext('offset', '0')) / divisions
|
||||
if item.tag == 'attributes' and item.find('transpose') is not None:
|
||||
transpose = fractions.Fraction(item.findtext('transpose/chromatic', '0')) + 12 * fractions.Fraction(item.findtext('transpose/octave-change', '0'))
|
||||
elif item.tag == 'backup':
|
||||
cursor -= fractions.Fraction(item.findtext('duration')) / divisions
|
||||
elif item.tag == 'forward':
|
||||
cursor += fractions.Fraction(item.findtext('duration')) / divisions
|
||||
extent = max(extent, cursor)
|
||||
elif item.tag == 'harmony':
|
||||
active = {'time': str(absolute + cursor + offset), 'measure': index + 1, 'offset': str(cursor + offset),
|
||||
'root': item.findtext('root/root-step'), 'alter': item.findtext('root/root-alter', '0'),
|
||||
'kind': item.findtext('kind'), 'display': item.find('kind').get('text') if item.find('kind') is not None else None,
|
||||
'bass': xml.etree.ElementTree.tostring(item.find('bass'), encoding='unicode') if item.find('bass') is not None else None,
|
||||
'degrees': [{'value': d.findtext('degree-value'), 'alter': d.findtext('degree-alter'), 'type': d.findtext('degree-type')} for d in item.findall('degree')]}
|
||||
result['harmonies'].append(active)
|
||||
elif item.tag=='direction':
|
||||
elif item.tag == 'direction':
|
||||
for words in item.findall('direction-type/words'):
|
||||
result['annotations'].append({'time':str(absolute+cursor+offset),'measure':index+1,'text':''.join(words.itertext()),'interpretation':'descriptive; no production permission'})
|
||||
elif item.tag=='note':
|
||||
duration=F(item.findtext('duration','0'))/divisions
|
||||
start=last_start if item.find('chord') is not None else cursor
|
||||
end=start+duration;voice=item.findtext('voice','1');p=item.find('pitch')
|
||||
encoded={'step':p.findtext('step'),'alter':p.findtext('alter','0'),'octave':int(p.findtext('octave'))} if p is not None else None
|
||||
kinds={t.get('type') for t in item.findall('tie')} | {t.get('type') for t in item.findall('notations/tied')}
|
||||
event={'start':str(absolute+start),'end':str(absolute+end),'measure':index+1,'offset':str(start),'pitch':encoded,'rest':item.find('rest') is not None,
|
||||
'written_type':item.findtext('type'),'dots':len(item.findall('dot')),'voice':voice,'ties':sorted(kinds),
|
||||
'sounding_transposition':str(-12 if pitch_convention=='guitar-written' else transpose),
|
||||
'tablature':{'string':int(item.findtext('notations/technical/string')),'fret':int(item.findtext('notations/technical/fret'))} if item.find('notations/technical/string') is not None and item.find('notations/technical/fret') is not None else None,
|
||||
'time_modification':ET.tostring(item.find('time-modification'),encoding='unicode') if item.find('time-modification') is not None else None,
|
||||
'tuplets':[dict(t.attrib) for t in item.findall('notations/tuplet')],'grace':item.find('grace') is not None,'new_attack':'stop' not in kinds and p is not None}
|
||||
key=(voice,json.dumps(encoded,sort_keys=True))
|
||||
result['annotations'].append({'time': str(absolute + cursor + offset), 'measure': index + 1, 'text': ''.join(words.itertext()), 'interpretation': 'descriptive; no production permission'})
|
||||
elif item.tag == 'note':
|
||||
duration = fractions.Fraction(item.findtext('duration', '0')) / divisions
|
||||
start = last_start if item.find('chord') is not None else cursor
|
||||
end = start + duration
|
||||
voice = item.findtext('voice', '1')
|
||||
p = item.find('pitch')
|
||||
encoded = {'step': p.findtext('step'), 'alter': p.findtext('alter', '0'), 'octave': int(p.findtext('octave'))} if p is not None else None
|
||||
kinds = {t.get('type') for t in item.findall('tie')} | {t.get('type') for t in item.findall('notations/tied')}
|
||||
event = {'start': str(absolute + start), 'end': str(absolute + end), 'measure': index + 1, 'offset': str(start), 'pitch': encoded, 'rest': item.find('rest') is not None,
|
||||
'written_type': item.findtext('type'), 'dots': len(item.findall('dot')), 'voice': voice, 'ties': sorted(kinds),
|
||||
'sounding_transposition': str(-12 if pitch_convention == 'guitar-written' else transpose),
|
||||
'tablature': {'string': int(item.findtext('notations/technical/string')), 'fret': int(item.findtext('notations/technical/fret'))} if item.find('notations/technical/string') is not None and item.find('notations/technical/fret') is not None else None,
|
||||
'time_modification': xml.etree.ElementTree.tostring(item.find('time-modification'), encoding='unicode') if item.find('time-modification') is not None else None,
|
||||
'tuplets': [dict(t.attrib) for t in item.findall('notations/tuplet')], 'grace': item.find('grace') is not None, 'new_attack': 'stop' not in kinds and p is not None}
|
||||
key = (voice, json.dumps(encoded, sort_keys=True))
|
||||
if 'stop' in kinds:
|
||||
old=pending.get(key)
|
||||
if old is None or result['notes'][old]['end']!=event['start']:
|
||||
result['discrepancies'].append({'measure':index+1,'issue':'unmatched/noncontiguous tie; retained literally'})
|
||||
else:event['continuation_of']=old
|
||||
if 'start' not in kinds:pending.pop(key,None)
|
||||
if 'start' in kinds:pending[key]=len(result['notes'])
|
||||
old = pending.get(key)
|
||||
if old is None or result['notes'][old]['end'] != event['start']:
|
||||
result['discrepancies'].append({'measure': index + 1, 'issue': 'unmatched/noncontiguous tie; retained literally'})
|
||||
else:
|
||||
event['continuation_of'] = old
|
||||
if 'start' not in kinds:
|
||||
pending.pop(key, None)
|
||||
if 'start' in kinds:
|
||||
pending[key] = len(result['notes'])
|
||||
result['notes'].append(event)
|
||||
for container in ('notations/technical/fingering','notations/technical/other-technical','notations/other-notation'):
|
||||
for container in ('notations/technical/fingering', 'notations/technical/other-technical', 'notations/other-notation'):
|
||||
for label in item.findall(container):
|
||||
text=''.join(label.itertext()).strip()
|
||||
if text:result['annotations'].append({'time':str(absolute+start),'measure':index+1,'text':text,
|
||||
'container':container,'interpretation':'Original note annotation; container does not establish musical permission or guitar fingering.'})
|
||||
if item.find('chord') is None and item.find('grace') is None:cursor=end
|
||||
last_start,last_duration=start,duration;extent=max(extent,end,cursor)
|
||||
if extent!=nominal and m.get('implicit')!='yes':result['discrepancies'].append({'measure':index+1,'issue':f'encoded span {extent} differs from nominal {nominal}; no padding applied'})
|
||||
result['measures'].append({'number':m.get('number'),'start':str(absolute),'duration':str(extent),'implicit':m.get('implicit'),'initial_harmony_known':any(F(h['time'])<=absolute for h in result['harmonies'])})
|
||||
absolute+=extent
|
||||
if pending:result['discrepancies'].append({'issue':'unterminated ties retained'})
|
||||
result['harmonies'].sort(key=lambda h:F(h['time']))
|
||||
text = ''.join(label.itertext()).strip()
|
||||
if text:
|
||||
result['annotations'].append({'time': str(absolute + start), 'measure': index + 1, 'text': text,
|
||||
'container': container, 'interpretation': 'Original note annotation; container does not establish musical permission or guitar fingering.'})
|
||||
if item.find('chord') is None and item.find('grace') is None:
|
||||
cursor = end
|
||||
last_start, last_duration = start, duration
|
||||
extent = max(extent, end, cursor)
|
||||
if extent != nominal and m.get('implicit') != 'yes':
|
||||
result['discrepancies'].append({'measure': index + 1, 'issue': f'encoded span {extent} differs from nominal {nominal}; no padding applied'})
|
||||
result['measures'].append({'number': m.get('number'), 'start': str(absolute), 'duration': str(extent), 'implicit': m.get('implicit'), 'initial_harmony_known': any(fractions.Fraction(h['time']) <= absolute for h in result['harmonies'])})
|
||||
absolute += extent
|
||||
if pending:
|
||||
result['discrepancies'].append({'issue': 'unterminated ties retained'})
|
||||
result['harmonies'].sort(key=lambda h: fractions.Fraction(h['time']))
|
||||
# Do not run the strict score importer over these references: annotations,
|
||||
# unusual timing and high encoded pitches are evidence, not repair requests.
|
||||
from generator import harmony, model, fretboard
|
||||
for event in result['notes']:
|
||||
if not event['pitch']:continue
|
||||
p=event['pitch'];number=12*(p['octave']+1)+model.NATURAL[p['step'].lower()]+F(p['alter'])
|
||||
event['encoded_midi']=str(number)
|
||||
event['sounding_midi']=str(number+F(event['sounding_transposition']))
|
||||
event['legal_positions_at_encoded_pitch']=len(fretboard.single_positions_for_pitch(number))
|
||||
event['legal_positions_at_sounding_pitch']=len(fretboard.single_positions_for_pitch(F(event['sounding_midi'])))
|
||||
event['harmonic_spans']=[]
|
||||
start,end=F(event['start']),F(event['end'])
|
||||
changes=[F(h['time']) for h in result['harmonies'] if start<F(h['time'])<end]
|
||||
for a,b in zip([start,*changes],[*changes,end]):
|
||||
current=next((h for h in reversed(result['harmonies']) if F(h['time'])<=a),None)
|
||||
info={'start':str(a),'end':str(b),'chord':current,'chord_degrees':[]}
|
||||
if not event['pitch']:
|
||||
continue
|
||||
p = event['pitch']
|
||||
number = 12 * (p['octave'] + 1) + generator.model.NATURAL[p['step'].lower()] + fractions.Fraction(p['alter'])
|
||||
event['encoded_midi'] = str(number)
|
||||
event['sounding_midi'] = str(number + fractions.Fraction(event['sounding_transposition']))
|
||||
event['legal_positions_at_encoded_pitch'] = len(generator.fretboard.single_positions_for_pitch(number))
|
||||
event['legal_positions_at_sounding_pitch'] = len(generator.fretboard.single_positions_for_pitch(fractions.Fraction(event['sounding_midi'])))
|
||||
event['harmonic_spans'] = []
|
||||
start, end = fractions.Fraction(event['start']), fractions.Fraction(event['end'])
|
||||
changes = [fractions.Fraction(h['time']) for h in result['harmonies'] if start < fractions.Fraction(h['time']) < end]
|
||||
for a, b in zip([start, *changes], [*changes, end]):
|
||||
current = next((h for h in reversed(result['harmonies']) if fractions.Fraction(h['time']) <= a), None)
|
||||
info = {'start': str(a), 'end': str(b), 'chord': current, 'chord_degrees': []}
|
||||
if current and current['root']:
|
||||
try:
|
||||
chord=harmony.parse_symbol(training_chord(current));root=model.NATURAL[chord['root']['letter']]+model.alteration(chord['root']['alter'])
|
||||
info['chord_degrees']=[d for d,alter in harmony.degrees(chord).items() if (root+harmony.degree_semitones(d,alter)-number)%12==0]
|
||||
info['root_relative_semitones']=str((number-root)%12)
|
||||
info['spelled_simple_degree']=(model.LETTERS.index(p['step'].lower())-model.LETTERS.index(chord['root']['letter']))%7+1
|
||||
except (ValueError,KeyError,AttributeError) as error:
|
||||
info['uncertainty']=str(error)
|
||||
else:info['uncertainty']='No written/carried harmony; none invented.'
|
||||
chord = generator.harmony.parse_symbol(training_chord(current))
|
||||
root = generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter'])
|
||||
info['chord_degrees'] = [d for d, alter in generator.harmony.degrees(chord).items() if (root + generator.harmony.degree_semitones(d, alter) - number) % 12 == 0]
|
||||
info['root_relative_semitones'] = str((number - root) % 12)
|
||||
info['spelled_simple_degree'] = (generator.model.LETTERS.index(p['step'].lower()) - generator.model.LETTERS.index(chord['root']['letter'])) % 7 + 1
|
||||
except (ValueError, KeyError, AttributeError) as error:
|
||||
info['uncertainty'] = str(error)
|
||||
else:
|
||||
info['uncertainty'] = 'No written/carried harmony; none invented.'
|
||||
event['harmonic_spans'].append(info)
|
||||
sounded=[];tie_roots={}
|
||||
for i,n in enumerate(result['notes']):
|
||||
if not n['pitch']:continue
|
||||
previous=n.get('continuation_of')
|
||||
sounded = []
|
||||
tie_roots = {}
|
||||
for i, n in enumerate(result['notes']):
|
||||
if not n['pitch']:
|
||||
continue
|
||||
previous = n.get('continuation_of')
|
||||
if previous is not None and previous in tie_roots:
|
||||
root=tie_roots[previous];sounded[root]['end']=n['end'];sounded[root]['notation_events'].append(i);tie_roots[i]=root
|
||||
root = tie_roots[previous]
|
||||
sounded[root]['end'] = n['end']
|
||||
sounded[root]['notation_events'].append(i)
|
||||
tie_roots[i] = root
|
||||
else:
|
||||
tie_roots[i]=len(sounded);sounded.append({'start':n['start'],'end':n['end'],'pitch':n['pitch'],
|
||||
'encoded_midi':n['encoded_midi'],'sounding_midi':n['sounding_midi'],'measure':n['measure'],'notation_events':[i]})
|
||||
result['sounding_events']=sounded
|
||||
first=form_start or next((h['measure'] for h in result['harmonies']),None)
|
||||
offset=F(result['measures'][first-1]['start']) if first else None
|
||||
result['alignment']={'form_start_measure':first,'encoded_offset':str(offset) if offset is not None else None,'basis':'explicit override' if form_start else 'first written harmony; review required','preceding_material':'retained, without invented harmony'}
|
||||
tie_roots[i] = len(sounded)
|
||||
sounded.append({'start': n['start'], 'end': n['end'], 'pitch': n['pitch'],
|
||||
'encoded_midi': n['encoded_midi'], 'sounding_midi': n['sounding_midi'], 'measure': n['measure'], 'notation_events': [i]})
|
||||
result['sounding_events'] = sounded
|
||||
first = form_start or next((h['measure'] for h in result['harmonies']), None)
|
||||
offset = fractions.Fraction(result['measures'][first - 1]['start']) if first else None
|
||||
result['alignment'] = {'form_start_measure': first, 'encoded_offset': str(offset) if offset is not None else None, 'basis': 'explicit override' if form_start else 'first written harmony; review required', 'preceding_material': 'retained, without invented harmony'}
|
||||
for label in result['annotations']:
|
||||
t=F(label['time']); current=[h for h in result['harmonies'] if F(h['time'])<=t]
|
||||
future=next((h for h in result['harmonies'] if F(h['time'])>t),None)
|
||||
label['current_harmony']=current[-1] if current else None
|
||||
label['following_harmony']=future
|
||||
label['events']=[n for n in result['notes'] if F(n['start'])<=t<F(n['end']) or t<=F(n['start'])<t+1]
|
||||
label['uncertainty']='Label meaning must be assessed from these events and harmonic time; label preserved verbatim.'
|
||||
expected={'b9':F(1),'#9':F(3),'b13':F(8),'#11':F(6),'6':F(9)}.get(label['text'].strip())
|
||||
t = fractions.Fraction(label['time'])
|
||||
current = [h for h in result['harmonies'] if fractions.Fraction(h['time']) <= t]
|
||||
future = next((h for h in result['harmonies'] if fractions.Fraction(h['time']) > t), None)
|
||||
label['current_harmony'] = current[-1] if current else None
|
||||
label['following_harmony'] = future
|
||||
label['events'] = [n for n in result['notes'] if fractions.Fraction(n['start']) <= t < fractions.Fraction(n['end']) or t <= fractions.Fraction(n['start']) < t + 1]
|
||||
label['uncertainty'] = 'Label meaning must be assessed from these events and harmonic time; label preserved verbatim.'
|
||||
expected = {'b9': fractions.Fraction(1), '#9': fractions.Fraction(3), 'b13': fractions.Fraction(8), '#11': fractions.Fraction(6), '6': fractions.Fraction(9)}.get(label['text'].strip())
|
||||
if expected is not None:
|
||||
relationships=[s.get('root_relative_semitones') for n in label['events'] for s in n.get('harmonic_spans',[])]
|
||||
label['literal_interval_present']=str(expected) in relationships
|
||||
if not label['literal_interval_present']:label['uncertainty']='No matching interval in the immediate annotated context; label retained, intended scope uncertain.'
|
||||
relationships = [s.get('root_relative_semitones') for n in label['events'] for s in n.get('harmonic_spans', [])]
|
||||
label['literal_interval_present'] = str(expected) in relationships
|
||||
if not label['literal_interval_present']:
|
||||
label['uncertainty'] = 'No matching interval in the immediate annotated context; label retained, intended scope uncertain.'
|
||||
# Lead in and Bluesy stay contextual descriptions, never enclosure permits.
|
||||
return result
|
||||
|
||||
from generator import harmony,model
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
|
||||
def training_chord(h):
|
||||
result={'root':{'letter':h['root'].lower(),'alter':model.rational(F(h['alter']))},'kind':h['kind'],'degrees':[]}
|
||||
result = {'root': {'letter': h['root'].lower(), 'alter': generator.model.rational(fractions.Fraction(h['alter']))}, 'kind': h['kind'], 'degrees': []}
|
||||
for d in h['degrees']:
|
||||
degree=int(d['value']);alter=F(d['alter'])
|
||||
if d['type']=='alter':alter+=harmony.KINDS.get(result['kind'],{}).get(degree,0)
|
||||
elif d['type']=='add' and (degree-1)%7==6:alter-=1
|
||||
result['degrees'].append({'value':degree,'alter':model.rational(alter),'type':d['type']})
|
||||
degree = int(d['value'])
|
||||
alter = fractions.Fraction(d['alter'])
|
||||
if d['type'] == 'alter':
|
||||
alter += generator.harmony.KINDS.get(result['kind'], {}).get(degree, 0)
|
||||
elif d['type'] == 'add' and (degree - 1) % 7 == 6:
|
||||
alter -= 1
|
||||
result['degrees'].append({'value': degree, 'alter': generator.model.rational(alter), 'type': d['type']})
|
||||
if h['bass']:
|
||||
import xml.etree.ElementTree as ET
|
||||
bass=ET.fromstring(h['bass']);result['bass']={'letter':bass.findtext('bass-step').lower(),'alter':model.rational(F(bass.findtext('bass-alter','0')))}
|
||||
display=h['display']
|
||||
bass = xml.etree.ElementTree.fromstring(h['bass'])
|
||||
result['bass'] = {'letter': bass.findtext('bass-step').lower(), 'alter': generator.model.rational(fractions.Fraction(bass.findtext('bass-alter', '0')))}
|
||||
display = h['display']
|
||||
if display:
|
||||
# Soundslice exports an optional chord's suffix with a trailing closing
|
||||
# parenthesis, but its actual root separately. Evidence stays untouched.
|
||||
if display.endswith(')') and '(' not in display:display=display[:-1]
|
||||
harmony.normalize_text(result,display)
|
||||
return harmony.symbol(result).strip('()')
|
||||
if display.endswith(')') and '(' not in display:
|
||||
display = display[:-1]
|
||||
generator.harmony.normalize_text(result, display)
|
||||
return generator.harmony.symbol(result).strip('()')
|
||||
|
||||
|
||||
def validate_harmony(document,evidence,identity):
|
||||
source_chords=[];cursor=F()
|
||||
def validate_harmony(document, evidence, identity):
|
||||
source_chords = []
|
||||
cursor = fractions.Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
source_chords.extend((cursor+model.fraction(h['onset']),harmony.symbol(h).strip('()')) for h in measure['harmonies'])
|
||||
cursor+=model.fraction(measure['duration'])
|
||||
authored=[(F(h['time']),training_chord(h)) for h in evidence['harmonies']]
|
||||
if authored!=source_chords:raise ValueError(identity+': authored harmony differs from authoritative source')
|
||||
source_chords.extend((cursor + generator.model.fraction(h['onset']), generator.harmony.symbol(h).strip('()')) for h in measure['harmonies'])
|
||||
cursor += generator.model.fraction(measure['duration'])
|
||||
authored = [(fractions.Fraction(h['time']), training_chord(h)) for h in evidence['harmonies']]
|
||||
if authored != source_chords:
|
||||
raise ValueError(identity + ': authored harmony differs from authoritative source')
|
||||
|
||||
@@ -6,17 +6,17 @@ import dataclasses
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fractions import Fraction as F
|
||||
import pathlib
|
||||
import fractions
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1] / 'data' / 'composition'
|
||||
ROOT = pathlib.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', 'harmony', 'lead_ins', 'accompaniment')
|
||||
return _load(tuple((name, (Path(root)/(name+'.json')).read_text()) for name in names))
|
||||
return _load(tuple((name, (pathlib.Path(root) / (name + '.json')).read_text()) for name in names))
|
||||
|
||||
@functools.lru_cache(maxsize=16)
|
||||
def _load(contents):
|
||||
|
||||
Reference in New Issue
Block a user