Playback
ci / checks (push) Failing after 23m37s

This commit is contained in:
khodowany
2026-10-05 23:41:11 -05:00
parent ffdbe48258
commit 82349cf897
104 changed files with 4095 additions and 4354 deletions
+31 -26
View File
@@ -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.')
+14 -12
View File
@@ -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]:
+2 -3
View File
@@ -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))
+9 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+3 -3
View File
@@ -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():
+10 -4
View 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)
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+1 -1
View File
@@ -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]):
+110
View File
@@ -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")
+1 -1
View File
@@ -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
View File
@@ -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']
-260
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+6 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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)}
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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')
+4 -4
View File
@@ -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):