Cleanup
ci / checks (push) Failing after 23m22s

This commit is contained in:
khodowany
2026-10-08 02:13:45 -05:00
parent 300097a37e
commit bc7baea7c4
88 changed files with 18844 additions and 1335 deletions
+10
View File
@@ -49,6 +49,8 @@ The Zero menus use direct taps: release on a row to select it, tap switches to t
In score view, Zero and Pico share one gesture state machine: a second release within 220 ms of the first release produces previous, a hold of at least 700 ms produces select once, and other taps produce next. A late second release preserves both single taps; holding consumes a pending first tap. Zero schedules pending-tap deadlines without polling and discards canceled or obsolete timer callbacks. In score view, Zero and Pico share one gesture state machine: a second release within 220 ms of the first release produces previous, a hold of at least 700 ms produces select once, and other taps produce next. A late second release preserves both single taps; holding consumes a pending first tap. Zero schedules pending-tap deadlines without polling and discards canceled or obsolete timer callbacks.
Score loading uses a dedicated single-worker executor for SQLite reads and JSON parsing. Completion returns to the control executor. The loader retains one in-flight request, coalesces pending selections, and discards stale results, keeping database work off HTTP and playback polling.
## Score Generation ## Score Generation
The build runs `./modulus.py python -m generator` against `data/` and writes build-only assets under the CMake binary tree. Every song produces acoustic, electric, and vocal parts in its authored musical key. Source counts are discovered, not fixed in code. Generator-only optional chord markers are consumed before publishing. The build runs `./modulus.py python -m generator` against `data/` and writes build-only assets under the CMake binary tree. Every song produces acoustic, electric, and vocal parts in its authored musical key. Source counts are discovered, not fixed in code. Generator-only optional chord markers are consumed before publishing.
@@ -59,6 +61,14 @@ The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model and
### Musical Data ### Musical Data
Generator dependencies run from pure musical values and source analysis toward composition, assembly, and publication. `model` contains primitive values; `validation` checks score documents; `score` contains pure score operations; and `score_io` owns bundle/file validation. `source` owns canonical source identity and immutable source events. `arrange.form.FormPlan` supplies source-derived sections, ownership windows, and pickup stops to both accompaniment and role assembly.
`composition` generates one role from an explicit immutable `vocabulary.CompositionContext`; `production` schedules typed worker requests and publishes the assembled parts. Lower musical functions receive that context explicitly. The scoped vocabulary API remains available for standalone callers and tests. Cached vocabulary loads return owned copies so one caller cannot alter another caller's resources.
Role cache identities include the transitive generator imports of the composition entrypoint. Engraving cache identities similarly include renderer dependencies. Cache identities never seed musical choices. `cache` provides shared content identity and staged-file publication mechanics, while role, PNG, and PDF adapters retain their own validation. `engraving.worker` owns rendering and its bounded image cache; `engraving.pipeline` groups and schedules work. `performance` contains pure timing contracts, and `audio_assets` validates and copies supplied recordings.
The reviewed output contract is checked by `tests.test_output_contract` for complete synthetic role scores, including learned solos, and by `python -m tests.asset_contract <generated-assets-directory>` for every non-diagnostic asset. CTest runs the asset check against the normal build output. Musical output, JSON/catalog serialization, PNGs, and PDFs must remain unchanged during structural refactors; diagnostic text and cache identities may change.
`data/songs/` contains authoritative source scores. `data/training/manifest.json` pairs each authored solo with a source and a slot (1 or 2), and declares its pitch convention. Both slots train **one pooled model**. Slot 1 maps to acoustic playback and slot 2 to electric playback; slot and instrument names are not model features. The four XML files retain their notes, harmony, timing, ties, rests, Autumn Leaves pickup and All of Me's full-bar opening. Explanatory labels such as `Lead in`, `b9` and `6` were removed; they were not model features. Written chord extensions remain intact. `data/songs/` contains authoritative source scores. `data/training/manifest.json` pairs each authored solo with a source and a slot (1 or 2), and declares its pitch convention. Both slots train **one pooled model**. Slot 1 maps to acoustic playback and slot 2 to electric playback; slot and instrument names are not model features. The four XML files retain their notes, harmony, timing, ties, rests, Autumn Leaves pickup and All of Me's full-bar opening. Explanatory labels such as `Lead in`, `b9` and `6` were removed; they were not model features. Written chord extensions remain intact.
`data/models/solo.json` is an explicitly trained, versioned statistical model. Its short-context counts, rhythm and register statistics are learned from the paired examples. Provenance records input hashes and the training version. This is supervised use of the supplied solos, not independence from them and not a neural network pretrained on an external corpus. `data/models/solo.json` is an explicitly trained, versioned statistical model. Its short-context counts, rhythm and register statistics are learned from the paired examples. Provenance records input hashes and the training version. This is supervised use of the supplied solos, not independence from them and not a neural network pretrained on an external corpus.
+11 -11
View File
@@ -2,14 +2,14 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os
import pathlib import pathlib
import platform
import shutil import shutil
import tempfile import tempfile
import time import time
import modulus.filesystem import generator.audio_assets
import generator.arrange.generate
import generator.catalog import generator.catalog
import generator.engraving.cache import generator.engraving.cache
import generator.engraving.fonts import generator.engraving.fonts
@@ -17,12 +17,12 @@ import generator.engraving.pipeline
import generator.engraving.types import generator.engraving.types
import generator.parts import generator.parts
import generator.pico_artwork import generator.pico_artwork
import generator.performance
import generator.printing import generator.printing
import generator.production
import generator.score import generator.score
import generator.score_io
import generator.summary import generator.summary
import platform import modulus.filesystem
import os
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt") RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
@@ -87,7 +87,7 @@ def generate_assets(args: argparse.Namespace) -> None:
staged.mkdir(parents=True, exist_ok=True) staged.mkdir(parents=True, exist_ok=True)
copy_runtime_fonts(static_assets_dir, staged) copy_runtime_fonts(static_assets_dir, staged)
report('generating performance parts') 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) performance_count = generator.production.generate_tree(data_dir / 'songs', staged / 'songs', jobs=getattr(args, 'composition_jobs', 2), cache_dir=getattr(args, 'composition_cache', None), progress=progress, timings=timings)
if getattr(args, 'trace_level', 'summary') == 'summary': if getattr(args, 'trace_level', 'summary') == 'summary':
for path in (staged / 'analysis').glob('*.roles'): for path in (staged / 'analysis').glob('*.roles'):
roles = json.loads(path.read_text()) roles = json.loads(path.read_text())
@@ -95,13 +95,13 @@ def generate_assets(args: argparse.Namespace) -> None:
trace = role.get('details', {}).get('selection_trace', {}) trace = role.get('details', {}).get('selection_trace', {})
trace.pop('decisions', None) trace.pop('decisions', None)
path.write_text(json.dumps(roles, sort_keys=True, indent=2)) path.write_text(json.dumps(roles, sort_keys=True, indent=2))
generator.score.validate_generated_tree(staged) generator.score_io.validate_generated_tree(staged)
if args.check: if args.check:
generator.summary.validate_expected_performance_summary(pathlib.Path(__file__).resolve().parents[1] / 'tests' / 'fixtures' / 'performance_summary.json', staged / 'songs') generator.summary.validate_expected_performance_summary(pathlib.Path(__file__).resolve().parents[1] / 'tests' / 'fixtures' / 'performance_summary.json', staged / 'songs')
report('copying supplied recordings') report('copying supplied recordings')
song_ids = {generator.score.slugify(path.stem) for path in (data_dir / 'songs').glob('*.xml')} 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) audio_assets = generator.audio_assets.copy_audio(static_assets_dir, staged, song_ids)
generator.performance.validate_recording_lengths(staged, audio_assets) generator.audio_assets.validate_recording_lengths(staged, audio_assets)
report('engraving pages') 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) 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('generating printable parts') report('generating printable parts')
@@ -109,7 +109,7 @@ def generate_assets(args: argparse.Namespace) -> None:
report('rendering Pico titles') report('rendering Pico titles')
generator.pico_artwork.generate_titles(staged, static_assets_dir / 'fonts' / 'petaluma') generator.pico_artwork.generate_titles(staged, static_assets_dir / 'fonts' / 'petaluma')
report('validating score, page, and audio assets') report('validating score, page, and audio assets')
generator.score.validate_generated_tree(staged, validated_pages) generator.score_io.validate_generated_tree(staged, validated_pages)
report('building catalog database') report('building catalog database')
generator.catalog.build_catalog(staged, audio_assets=audio_assets) generator.catalog.build_catalog(staged, audio_assets=audio_assets)
with modulus.filesystem.publish_directories([(staged, output_dir)]): with modulus.filesystem.publish_directories([(staged, output_dir)]):
+2 -9
View File
@@ -1,20 +1,13 @@
"""One learned accompaniment per instrument, reused at authored form positions.""" """One learned accompaniment per instrument, reused at authored form positions."""
from __future__ import annotations from __future__ import annotations
import copy
import fractions import fractions
import generator.accompaniment import generator.accompaniment
import generator.acoustic
import generator.acoustic_voicing
import generator.electric
import generator.guitar
import generator.fretboard
import generator.model
import generator.parts
import generator.score
import generator.arrange.form import generator.arrange.form
import generator.arrange.pickup import generator.arrange.pickup
import generator.model
import generator.score
def harmonic_key(measure, previous): def harmonic_key(measure, previous):
+100
View File
@@ -0,0 +1,100 @@
"""Assemble source-defined role windows without composing or respelling."""
from __future__ import annotations
import copy
import fractions
import generator.accompaniment
import generator.arrange.pickup
import generator.composition_types
import generator.fretboard
import generator.model
import generator.parts
import generator.validation
def mask(document, allowed):
"""Apply source-defined role exclusions, without observing another part."""
absolute = fractions.Fraction()
for measure in document['staves'][0]['measures']:
length = generator.model.fraction(measure['duration'])
for voice in measure['voices']:
selected = []
for n in voice['notes']:
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'] = 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'] = generator.arrange.pickup.retain_links(staff['measures'], staff.get('tuplets', []))
def fill(notes, length, prefix, voice='voice_arranged'):
notes = sorted(notes, key=lambda n: generator.model.fraction(n['onset']))
result = []
cursor = fractions.Fraction()
def rests(end):
nonlocal cursor
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 = 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 assemble(source, results):
"""Union prevalidated role windows; no selection or pitch/fingering rewrite."""
documents = {}
for part in generator.parts.PART_TYPES:
selected = [results[r]['document'] for r in generator.composition_types.ROLES if r.startswith(part + '_')]
document = copy.deepcopy(selected[0])
staff = document['staves'][0]
if part == 'vocal':
documents[part] = document
continue
staff['tuplets'] = []
staff['octave_shifts'] = []
staff['scale_spans'] = []
for other in selected:
for field in ('tuplets', 'octave_shifts', 'scale_spans'):
staff[field].extend(copy.deepcopy(other['staves'][0].get(field, [])))
for i, m in enumerate(staff['measures']):
# Tuplet rests belong to the source/role rhythm and must survive union.
lanes = {}
for d in selected:
for voice in d['staves'][0]['measures'][i]['voices']:
lane = voice['id'] if part == 'acoustic' and voice['id'] in ('upper', 'bass') else 'voice_arranged'
sounding = [copy.deepcopy(n) for n in voice['notes'] if n['pitches'] or n.get('tuplet_ids')]
if sounding:
lanes.setdefault(lane, []).extend(sounding)
lanes = lanes or {'voice_arranged': []}
m['voices'] = [{'id': lane, 'notes': fill(notes, generator.model.fraction(m['duration']), m['id'] + '_' + lane, lane)} for lane, notes in lanes.items()]
# 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'] = generator.arrange.pickup.retain_links(staff['measures'], staff['tuplets'])
owners = {n['id']: v['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
for span in staff['tuplets']:
span['voice'] = owners[span['start']]
generator.validation.validate_document(document)
generator.fretboard.validate_document(document)
documents[part] = document
return documents
+9 -23
View File
@@ -4,44 +4,30 @@ import fractions
import generator.accompaniment import generator.accompaniment
import generator.acoustic_voicing import generator.acoustic_voicing
import generator.arrange.ending_plan
import generator.comping import generator.comping
import generator.fretboard import generator.fretboard
import generator.model import generator.model
import generator.parts import generator.parts
import generator.score import generator.score
import generator.vocabulary
BARS = 3
def template(source):
result = copy.deepcopy(source[-1])
# An ending is three full bars in the final active meter, never a pickup.
for change in result.get('attributes', []):
for key in ('meter', 'key', 'clef', 'transpose', 'staff_details'):
if key in change:
result[key] = copy.deepcopy(change[key])
result.update(duration=generator.model.rational(generator.model.meter_length(result['meter'])), implicit=False,
attributes=[], harmonies=[], voices=[], tempos=[], section_label='')
result['time_signature'] = generator.model.meter_label(result['meter'])
return result
def duration(source):
return BARS * generator.model.fraction(template(source)['duration']) if source else fractions.Fraction()
def chord(source): def chord(source):
effective = [{**m, 'harmonies': [h for h in m['harmonies'] if not h.get('chord_optional')]} for m in source] effective = [{**m, 'harmonies': [h for h in m['harmonies'] if not h.get('chord_optional')]} for m in source]
prior = generator.accompaniment.carried_chords(effective)[-1] prior = generator.accompaniment.carried_chords(effective)[-1]
return generator.accompaniment.active_chord(effective[-1], generator.model.fraction(effective[-1]['duration']), prior) return generator.accompaniment.active_chord(effective[-1], generator.model.fraction(effective[-1]['duration']), prior)
def append(source, output, part, *, sounding=False, center=55): def append(source, output, part, *, sounding=False, center=55, composition=None):
composition = composition or generator.vocabulary.current()
if not source or not output: if not source or not output:
return return
model = template(source) model = generator.arrange.ending_plan.template(source)
harmony = chord(source) harmony = chord(source)
grip = () grip = ()
if sounding: if sounding:
if harmony is None: if harmony is None:
raise ValueError('Ending: final performed harmony is missing or N.C.; cannot invent a closing chord') raise ValueError('Ending: final performed harmony is missing or N.C.; cannot invent a closing chord')
options = generator.comping.voicings(generator.comping.semantic_key(harmony), part) options = generator.comping.voicings(generator.comping.semantic_key(harmony), part, composition=composition)
recent = {} recent = {}
for m in reversed(output): for m in reversed(output):
for n in sorted(generator.score.measure_notes(m), key=lambda n: generator.model.fraction(n['onset']), reverse=True): for n in sorted(generator.score.measure_notes(m), key=lambda n: generator.model.fraction(n['onset']), reverse=True):
@@ -55,10 +41,10 @@ def append(source, output, part, *, sounding=False, center=55):
if part == 'acoustic': if part == 'acoustic':
bass = next((recent[s] for s in (5, 6) if s in recent), None) bass = next((recent[s] for s in (5, 6) if s in recent), None)
previous = generator.acoustic_voicing.Voicing(bass, *(recent[s] for s in (4, 3, 2))) if bass and all(s in recent for s in (4, 3, 2)) else () previous = generator.acoustic_voicing.Voicing(bass, *(recent[s] for s in (4, 3, 2))) if bass and all(s in recent for s in (4, 3, 2)) else ()
grip = min(options, key=lambda v: (generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center).moved(generator.comping.movement(part, previous, v)), v)) grip = min(options, key=lambda v: (generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center, composition=composition).moved(generator.comping.movement(part, previous, v)), v))
length = generator.model.fraction(model['duration']) length = generator.model.fraction(model['duration'])
previous = None previous = None
for index in range(BARS): for index in range(generator.arrange.ending_plan.BARS):
measure = generator.parts.build_measure(model, f'measure_{len(output) + 1}', 'rest', part, None, 'Ending' if index == 0 else '') measure = generator.parts.build_measure(model, f'measure_{len(output) + 1}', 'rest', part, None, 'Ending' if index == 0 else '')
measure['ending_sustain'] = True measure['ending_sustain'] = True
if index == 0 and harmony: if index == 0 and harmony:
@@ -77,4 +63,4 @@ def append(source, output, part, *, sounding=False, center=55):
cursor += duration cursor += duration
measure['voices'] = [{'id': 'voice_arranged', 'notes': notes}] measure['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
output.append(measure) output.append(measure)
generator.score.reidentify(output[-BARS:], []) generator.score.reidentify(output[-generator.arrange.ending_plan.BARS:], [])
+22
View File
@@ -0,0 +1,22 @@
"""Source-derived ending geometry, independent of instrument composition."""
import copy
import fractions
import generator.model
BARS = 3
def template(source):
result = copy.deepcopy(source[-1])
# An ending is three full bars in the final active meter, never a pickup.
for change in result.get('attributes', []):
for key in ('meter', 'key', 'clef', 'transpose', 'staff_details'):
if key in change:
result[key] = copy.deepcopy(change[key])
result.update(duration=generator.model.rational(generator.model.meter_length(result['meter'])), implicit=False,
attributes=[], harmonies=[], voices=[], tempos=[], section_label='')
result['time_signature'] = generator.model.meter_label(result['meter'])
return result
def duration(source):
return BARS * generator.model.fraction(template(source)['duration']) if source else fractions.Fraction()
+58
View File
@@ -1,8 +1,12 @@
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import fractions
import typing import typing
import generator.arrange.ending_plan
import generator.arrange.pickup
import generator.model
import generator.score import generator.score
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
@@ -55,3 +59,57 @@ def full_arrangement_sections(measures: list[dict], part_type: str) -> list[Arra
for label, start, count, index in specs for label, start, count, index in specs
if count > 0 if count > 0
] ]
@dataclasses.dataclass(frozen=True)
class FormPlan:
sections: tuple[ArrangementSection, ...]
windows: tuple[tuple[fractions.Fraction, fractions.Fraction, str], ...]
stops: tuple[tuple[int, fractions.Fraction] | None, ...]
@classmethod
def read(cls, document, part):
source = generator.score.source_staff(document)['measures']
sections = tuple(full_arrangement_sections(source, part))
stops = tuple(section_stop(source, section, sections[i + 1] if i + 1 < len(sections) else None) for i, section in enumerate(sections))
return cls(sections, _windows(document, part, sections), stops)
def section_stop(source, section, following):
if following is None or not generator.score.body_start_measure_index(source):
return None
last = section.source_measure_index + section.measure_count - 1
pad = generator.arrange.pickup.offset(source[last], source[0])
if following.role == 'slash':
return last, pad
if following.role == 'melody' and generator.arrange.pickup.enters(following, source):
attacks = [generator.model.fraction(note['onset']) for note in generator.score.measure_notes(source[0]) if note['pitches']]
if attacks:
return last, pad + min(attacks)
return None
def windows(document, part):
return FormPlan.read(document, part).windows
def _windows(document, part, sections):
source = generator.score.source_staff(document)['measures']
result, cursor = [], fractions.Fraction()
for section in sections:
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 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 - 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 - generator.model.fraction(source[0]['duration'])
a, _, old = result[-1]
result[-1] = (a, start, old)
result.append((start, end, part + '_' + role))
cursor = end
result.append((cursor, cursor + generator.arrange.ending_plan.duration(source), part + ('_accompaniment' if part != 'vocal' else '_rest')))
return tuple(result)
+13 -27
View File
@@ -2,36 +2,31 @@ from __future__ import annotations
import copy import copy
import fractions import fractions
import pathlib
import generator.accompaniment import generator.accompaniment
import generator.arrange.accompaniment
import generator.acoustic import generator.acoustic
import generator.acoustic_voicing import generator.acoustic_voicing
import generator.arrange.ending import generator.arrange.ending
import generator.arrange.form import generator.arrange.form
import generator.arrange.pickup import generator.arrange.pickup
import generator.comping
import generator.electric import generator.electric
import generator.fretboard import generator.fretboard
import generator.guitar import generator.guitar
import generator.melody import generator.melody
import generator.model import generator.model
import generator.musicxml
import generator.parts import generator.parts
import generator.score import generator.score
import generator.solo.arrangement
import generator.comping
import generator.vocabulary import generator.vocabulary
import generator.production
FINAL_SECTION_INDEX = 5 FINAL_SECTION_INDEX = 5
def arrange_document(document: dict, part_type: str) -> dict: def arrange_document(document: dict, part_type: str, *, composition=None) -> dict:
"""Canonical source-only template; solos are inserted by production roles.""" """Canonical source-only template; solos are inserted by production roles."""
composition = composition or generator.vocabulary.CompositionContext.create(generator.vocabulary.load(), part_type + '_accompaniment')
document = generator.parts.prepare_document(document) document = generator.parts.prepare_document(document)
with generator.vocabulary.using(generator.vocabulary.load(), part_type + '_accompaniment'): backing = generator.comping.Canonical(document, part_type, 'canonical', composition=composition) if part_type != 'vocal' else None
backing = generator.comping.Canonical(document, part_type, 'canonical') if part_type != 'vocal' else None return _arrange_document(document, part_type, accompaniment=backing, composition=composition)
return _arrange_document(document, part_type, accompaniment=backing)
def section_source(source: list[dict], section, next_section=None) -> list[dict]: def section_source(source: list[dict], section, next_section=None) -> list[dict]:
result = copy.deepcopy(source) result = copy.deepcopy(source)
@@ -44,12 +39,14 @@ def section_source(source: list[dict], section, next_section=None) -> list[dict]
generator.arrange.pickup.harmonies(destination, result[0]) generator.arrange.pickup.harmonies(destination, result[0])
return result return result
def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None, accompaniment=None) -> dict: def _arrange_document(document: dict, part_type: str, plan=None, *, owned=None, accompaniment=None, composition=None) -> dict:
composition = composition or generator.vocabulary.current()
if part_type not in generator.parts.PART_TYPES: if part_type not in generator.parts.PART_TYPES:
raise ValueError(f'unknown arrangement part: {part_type}') raise ValueError(f'unknown arrangement part: {part_type}')
staff = generator.score.source_staff(document) staff = generator.score.source_staff(document)
source = staff['measures'] source = staff['measures']
sections = generator.arrange.form.full_arrangement_sections(source, part_type) plan = plan or generator.arrange.form.FormPlan.read(document, part_type)
sections = plan.sections
output, tuplets, octave_shifts, voicing_origins = [], [], [], [] output, tuplets, octave_shifts, voicing_origins = [], [], [], []
absolute = fractions.Fraction() absolute = fractions.Fraction()
source_starts = [] source_starts = []
@@ -62,16 +59,8 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None) section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
previous = generator.accompaniment.carried_chords(section_measures) previous = generator.accompaniment.carried_chords(section_measures)
segment = [] segment = []
stop = None stop = plan.stops[index]
next_section = sections[index + 1] if index + 1 < len(sections) else None next_section = sections[index + 1] if index + 1 < len(sections) else None
if next_section and generator.score.body_start_measure_index(source):
last = section.source_measure_index + section.measure_count - 1
if next_section.role == 'slash':
stop = (last, generator.model.fraction(section_measures[last]['duration']) - generator.model.fraction(source[0]['duration']))
elif next_section.role == 'melody' and generator.arrange.pickup.enters(next_section, source):
attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(source[0]) if n['pitches']]
if attacks:
stop = (last, generator.model.fraction(section_measures[last]['duration']) - generator.model.fraction(source[0]['duration']) + min(attacks))
for si in range(section.source_measure_index, section.source_measure_index + section.measure_count): for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
m = section_measures[si] m = section_measures[si]
if role == 'accompaniment' and accompaniment is not None: if role == 'accompaniment' and accompaniment is not None:
@@ -111,7 +100,7 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
if sections and sections[-1].section_index == FINAL_SECTION_INDEX: if sections and sections[-1].section_index == FINAL_SECTION_INDEX:
generator.arrange.ending.append(source, output, part_type, generator.arrange.ending.append(source, output, part_type,
sounding=part_type != 'vocal' and (owned is None or 'accompaniment' in owned), sounding=part_type != 'vocal' and (owned is None or 'accompaniment' in owned),
center=generator.vocabulary.role(part_type + '_accompaniment').get('center', 55)) center=composition.role(part_type + '_accompaniment').get('center', 55), composition=composition)
tuplets = generator.arrange.pickup.retain_links(output, tuplets) tuplets = generator.arrange.pickup.retain_links(output, tuplets)
result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts) result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
result['metadata'].setdefault('tempo', 124) result['metadata'].setdefault('tempo', 124)
@@ -123,15 +112,12 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
generator.acoustic.combine(result) generator.acoustic.combine(result)
elif part_type == 'electric' and (owned is None or 'melody' in owned): elif part_type == 'electric' and (owned is None or 'melody' in owned):
generator.melody.harmonize(result) generator.melody.harmonize(result)
generator.guitar.place_guitar(result) generator.guitar.place_guitar(result, composition=composition)
if generator.accompaniment.connect_continuations(result): if generator.accompaniment.connect_continuations(result):
generator.guitar.place_guitar(result) generator.guitar.place_guitar(result, composition=composition)
# Source labels identify the head while arranging; published labels identify # Source labels identify the head while arranging; published labels identify
# the complete performance, including its lead-in and repeated choruses. # the complete performance, including its lead-in and repeated choruses.
for number, measure in enumerate(output, 1): for number, measure in enumerate(output, 1):
measure['number'] = str(number) measure['number'] = str(number)
generator.fretboard.validate_document(result) generator.fretboard.validate_document(result)
return result 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:
return generator.production.generate_tree(source_dir, output_dir, jobs=composition_jobs, cache_dir=cache_dir, progress=progress, timings=timings)
+68
View File
@@ -0,0 +1,68 @@
"""Validate and copy supplied recordings without synthesis or transformation."""
from __future__ import annotations
import fractions
import hashlib
import json
import re
import shutil
import wave
import generator.model
import generator.performance
TEMPOS = tuple(range(60, 121, 10))
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 = generator.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")
+82
View File
@@ -0,0 +1,82 @@
"""Content identities and atomic storage shared by generator cache adapters."""
from __future__ import annotations
import ast
import contextlib
import functools
import hashlib
import json
import os
import pathlib
import shutil
import tempfile
ROOT = pathlib.Path(__file__).resolve().parent
@functools.lru_cache(maxsize=256)
def imports(content):
modules = set()
for node in ast.walk(ast.parse(content)):
if isinstance(node, ast.Import):
modules.update(alias.name for alias in node.names if alias.name.startswith('generator.'))
elif isinstance(node, ast.ImportFrom) and node.module and node.module.startswith('generator'):
modules.add(node.module)
modules.update(node.module + '.' + alias.name for alias in node.names)
return tuple(sorted(modules))
def source_identity(entrypoints):
"""Conservatively include the transitive imports of explicit stage roots.
Read bytes on every call, so edits in a long-lived build process invalidate
both code content and newly introduced dependencies. Only parsing is cached.
"""
pending, sources = list(entrypoints), {}
while pending:
module = pending.pop()
relative = module.split('.')[1:]
path = ROOT.joinpath(*relative).with_suffix('.py') if relative else ROOT / '__init__.py'
if not path.is_file():
path = ROOT.joinpath(*relative, '__init__.py')
if path in sources or not path.is_file():
continue
content = path.read_bytes()
sources[path] = hashlib.sha256(content).hexdigest()
pending.extend(imports(content))
pending.extend('.'.join(module.split('.')[:i]) for i in range(1, len(module.split('.'))))
return {path.relative_to(ROOT).as_posix(): sources[path] for path in sorted(sources)}
def fingerprint(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def write_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
temporary = None
try:
with tempfile.NamedTemporaryFile('w', dir=path.parent, delete=False) as output:
temporary = pathlib.Path(output.name)
json.dump(value, output, sort_keys=True)
os.replace(temporary, path)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
def has_symlink(path):
return any(parent.is_symlink() for parent in (path, *path.parents))
@contextlib.contextmanager
def stage_entry(entry, prefix):
if has_symlink(entry.parent):
raise ValueError('cache directories must not be symlinks')
entry.parent.mkdir(parents=True, exist_ok=True)
if entry.is_symlink() or entry.is_file():
entry.unlink(missing_ok=True)
entry.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix=prefix, dir=entry.parent) as temporary:
yield pathlib.Path(temporary)
def publish_files(entry, staged, names):
"""Publish complete files in order, with the artifact manifest last."""
for name in names:
target = entry / name
if target.is_dir() and not target.is_symlink():
shutil.rmtree(target)
(staged / name).replace(target)
+3 -3
View File
@@ -10,7 +10,7 @@ import sys
if __name__ == '__main__' and __package__ is None: if __name__ == '__main__' and __package__ is None:
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
import generator.performance import generator.audio_assets
APPLICATION_ID = 0x56414D50 APPLICATION_ID = 0x56414D50
SONG_COLUMNS = """ SONG_COLUMNS = """
@@ -58,12 +58,12 @@ def populate(connection: sqlite3.Connection, root: pathlib.Path, audio_assets=No
for path in sorted(root.glob('sounds/*.wav')): for path in sorted(root.glob('sounds/*.wav')):
if path.name not in ('beat1.wav', 'beat2.wav'): if path.name not in ('beat1.wav', 'beat2.wav'):
raise ValueError(f'{path}: unknown metronome sample') raise ValueError(f'{path}: unknown metronome sample')
audio_assets.append(generator.performance.describe_wav(path, root)) audio_assets.append(generator.audio_assets.describe_wav(path, root))
for path in sorted(root.glob('backing/*.wav')): 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) match = re.fullmatch(r'([a-z0-9]+(?:_[a-z0-9]+)*)_(60|70|80|90|100|110|120)\.wav', path.name)
if not match: if not match:
raise ValueError(f'{path}: invalid backing filename') raise ValueError(f'{path}: invalid backing filename')
audio_assets.append(generator.performance.describe_wav(path, root, song_id=match[1], bpm=int(match[2]))) audio_assets.append(generator.audio_assets.describe_wav(path, root, song_id=match[1], bpm=int(match[2])))
folder = root / 'songs' folder = root / 'songs'
if (folder.exists() or folder.is_symlink()) and not folder.is_dir(): if (folder.exists() or folder.is_symlink()) and not folder.is_dir():
raise ValueError(f'{folder}: score asset directory not found') raise ValueError(f'{folder}: score asset directory not found')
+8 -6
View File
@@ -1,10 +1,11 @@
"""Transposable guitar families, derived from the written chord and legal grips.""" """Transposable guitar families, derived from the written chord and legal grips."""
import generator.harmony
import generator.fretboard import generator.fretboard
import generator.harmony
import generator.model import generator.model
import generator.vocabulary import generator.vocabulary
def acoustic(chord, grip): def acoustic(chord, grip, *, composition=None):
composition = composition or generator.vocabulary.current()
degrees = generator.harmony.degrees(chord) degrees = generator.harmony.degrees(chord)
root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter'])) 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()} classes = {d: (root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
@@ -14,7 +15,7 @@ def acoustic(chord, grip):
kind = 'extended' if terminal and color else 'tetrad' if terminal else 'triad' kind = 'extended' if terminal and color else 'tetrad' if terminal else 'triad'
bass = next((d for d in (1, 5, 3) if classes.get(d) == grip[0].midi % 12), None) bass = next((d for d in (1, 5, 3) if classes.get(d) == grip[0].midi % 12), None)
slots = {'root': 1, 'fifth': 5, 'guide': guide, 'terminal': terminal, 'color': color} slots = {'root': 1, 'fifth': 5, 'guide': guide, 'terminal': terminal, 'color': color}
policy = generator.vocabulary.accompaniment('acoustic')['voicing_families'] policy = composition.accompaniment('acoustic')['voicing_families']
orders = policy[kind].get(f'{grip[0].string_number}/{bass}', ()) orders = policy[kind].get(f'{grip[0].string_number}/{bass}', ())
upper = tuple(p.midi % 12 for p in grip[1:]) upper = tuple(p.midi % 12 for p in grip[1:])
preferred = any(all(slots[s] in classes for s in order) and tuple(classes[slots[s]] for s in order) == upper for order in orders) preferred = any(all(slots[s] in classes for s in order) and tuple(classes[slots[s]] for s in order) == upper for order in orders)
@@ -37,10 +38,11 @@ def electric(grip):
return 'drop-two' return 'drop-two'
return 'close-position' if pitches and pitches[-1] - pitches[0] < 12 else 'compact-open' return 'close-position' if pitches and pitches[-1] - pitches[0] < 12 else 'compact-open'
def coverage(chord, options, part): def coverage(chord, options, part, *, composition=None):
composition = composition or generator.vocabulary.current()
if part == 'electric': if part == 'electric':
return {name: sum(electric(v) == name for v in options) for name in generator.vocabulary.accompaniment('electric')['voicing_families']['rank']} return {name: sum(electric(v) == name for v in options) for name in composition.accompaniment('electric')['voicing_families']['rank']}
families = [acoustic(chord, v) for v in options] families = [acoustic(chord, v, composition=composition) for v in options]
result = [] result = []
for string in (5, 6): for string in (5, 6):
for degree in (1, 5): for degree in (1, 5):
+32 -36
View File
@@ -2,26 +2,25 @@
from __future__ import annotations from __future__ import annotations
import copy import copy
import dataclasses import dataclasses
import fractions
import functools import functools
import json import json
import fractions
import typing import typing
import generator.accompaniment import generator.accompaniment
import generator.acoustic import generator.acoustic
import generator.comping_rhythm
import generator.chord_families
import generator.acoustic_voicing import generator.acoustic_voicing
import generator.arrange.form import generator.arrange.form
import generator.arrange.pickup import generator.chord_families
import generator.comping_rhythm
import generator.fretboard import generator.fretboard
import generator.guitar import generator.guitar
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.parts import generator.parts
import generator.score import generator.score
import generator.vocabulary
import generator.solo.policy import generator.solo.policy
import generator.vocabulary
def semantic_key(chord): 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) return json.dumps({k: chord[k] for k in ('root', 'kind', 'degrees', 'bass', 'inversion') if k in chord}, sort_keys=True)
@@ -38,12 +37,15 @@ def requirements(chord, part):
raise ValueError('acoustic accompaniment requires a written bass, root or fifth') raise ValueError('acoustic accompaniment requires a written bass, root or fifth')
return dataclasses.replace(result, basses=basses) return dataclasses.replace(result, basses=basses)
def voicings(key, part, *, composition=None):
return _voicings(key, part, composition or generator.vocabulary.current())
@functools.lru_cache(maxsize=2048) @functools.lru_cache(maxsize=2048)
def voicings(key, part): def _voicings(key, part, composition):
chord = json.loads(key) chord = json.loads(key)
required = requirements(chord, part) required = requirements(chord, part)
if part == 'electric': if part == 'electric':
options = generator.guitar.chord_positions(required, prune=False, profile='electric') options = generator.guitar.chord_positions(required, prune=False, profile='electric', composition=composition)
elif part == 'acoustic': elif part == 'acoustic':
degrees = generator.harmony.degrees(chord) degrees = generator.harmony.degrees(chord)
plain = degrees in ({1: 0, 3: 0, 5: 0}, {1: 0, 3: -1, 5: 0}) plain = degrees in ({1: 0, 3: 0, 5: 0}, {1: 0, 3: -1, 5: 0})
@@ -77,19 +79,21 @@ class VoicingCost(typing.NamedTuple):
def moved(self, distance): def moved(self, distance):
return self._replace(effort=self.effort + distance) return self._replace(effort=self.effort + distance)
def voicing_preferences(part, grip, center): def voicing_preferences(part, grip, center, *, composition=None):
return generator.guitar.voicing_preferences(grip, generator.guitar.voicing_preference(part + '_accompaniment', center)) composition = composition or generator.vocabulary.current()
return generator.guitar.voicing_preferences(grip, generator.guitar.voicing_preference(part + '_accompaniment', center, composition=composition))
def local_cost(part, key, grip, center): def local_cost(part, key, grip, center, *, composition=None):
composition = composition or generator.vocabulary.current()
required = requirements(json.loads(key), part) required = requirements(json.loads(key), part)
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center) register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
preference = sum(voicing_preferences(part, grip, center)) preference = sum(voicing_preferences(part, grip, center, composition=composition))
if part == 'electric': if part == 'electric':
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed) value = generator.guitar.voicing_cost(grip, 'electric', required.allowed, composition=composition)
return VoicingCost(value.extended_grips, 3 * value.string_shortfall + value.family_penalty, value.effort + preference, value.missing_tones, register) return VoicingCost(value.extended_grips, 3 * value.string_shortfall + value.family_penalty, value.effort + preference, value.missing_tones, register)
# Familiar degree orders on either bass string, then balanced whole-form # Familiar degree orders on either bass string, then balanced whole-form
# movement, sounding register and hand position. Neither is an absolute ban. # movement, sounding register and hand position. Neither is an absolute ban.
family = generator.chord_families.acoustic(json.loads(key), grip) family = generator.chord_families.acoustic(json.loads(key), grip, composition=composition)
return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), preference, len(set(required.allowed) - {p.midi % 12 for p in grip}), register) return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), preference, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False): def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False):
@@ -184,9 +188,10 @@ def split_measures(flat, inputs, part):
return tuple(output) return tuple(output)
class Canonical: class Canonical:
def __init__(self, document, part, seed): def __init__(self, document, part, seed, *, composition=None, plan=None):
self.composition = composition or generator.vocabulary.current()
self.part, self.seed = part, seed self.part, self.seed = part, seed
self.policy = generator.vocabulary.role(part + '_accompaniment') self.policy = self.composition.role(part + '_accompaniment')
self.cache, self._reports, self._rhythm_reports = {}, {}, {} self.cache, self._reports, self._rhythm_reports = {}, {}, {}
self.measures = generator.score.source_staff(document)['measures'] self.measures = generator.score.source_staff(document)['measures']
# A held accompaniment chord may continue into the bar containing the # A held accompaniment chord may continue into the bar containing the
@@ -194,7 +199,8 @@ class Canonical:
self.voice_id = self.measures[0]['voices'][0]['id'] self.voice_id = self.measures[0]['voices'][0]['id']
self.melody = generator.comping_rhythm.melody_contexts(document) self.melody = generator.comping_rhythm.melody_contexts(document)
self.first = generator.score.body_start_measure_index(self.measures) self.first = generator.score.body_start_measure_index(self.measures)
sections = generator.arrange.form.full_arrangement_sections(self.measures, part) plan = plan or generator.arrange.form.FormPlan.read(document, part)
sections = plan.sections
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(self.measures) self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(self.measures)
and b.source_measure_index == self.first for a, b in zip(sections, sections[1:])) and b.source_measure_index == self.first for a, b in zip(sections, sections[1:]))
carried = generator.accompaniment.carried_chords(self.measures) carried = generator.accompaniment.carried_chords(self.measures)
@@ -204,7 +210,7 @@ class Canonical:
size = 1 size = 1
if index + 1 < len(self.measures): if index + 1 < len(self.measures):
lengths = [generator.model.fraction(m['duration']) for m in self.measures[index:index + 2]] lengths = [generator.model.fraction(m['duration']) for m in self.measures[index:index + 2]]
if self.measures[index]['time_signature'] == self.measures[index + 1]['time_signature'] and any(tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths) for p in generator.vocabulary.accompaniment(part)['patterns']): if self.measures[index]['time_signature'] == self.measures[index + 1]['time_signature'] and any(tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths) for p in self.composition.accompaniment(part)['patterns']):
size = 2 size = 2
self.groups.append(tuple((self.measures[i], carried[i], i) for i in range(index, index + size))) self.groups.append(tuple((self.measures[i], carried[i], i) for i in range(index, index + size)))
index += size index += size
@@ -214,19 +220,9 @@ class Canonical:
for section in sections: for section in sections:
if section.role == 'accompaniment': if section.role == 'accompaniment':
self.pair_breaks.update((section.source_measure_index, section.source_measure_index + section.measure_count)) self.pair_breaks.update((section.source_measure_index, section.source_measure_index + section.measure_count))
for section, following in zip(sections, sections[1:]): self.pair_stops = [stop for section, stop in zip(sections, plan.stops) if section.role == 'accompaniment' and stop is not None]
if section.role != 'accompaniment' or not self.first:
continue
last = section.source_measure_index + section.measure_count - 1
pad = generator.arrange.pickup.offset(self.measures[last], self.measures[0])
if following.role == 'slash':
self.pair_stops.append((last, pad))
elif following.role == 'melody' and generator.arrange.pickup.enters(following, self.measures):
attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(self.measures[0]) if n['pitches']]
if attacks:
self.pair_stops.append((last, pad + min(attacks)))
breaks = [(i, fractions.Fraction()) for i in self.pair_breaks] + self.pair_stops breaks = [(i, fractions.Fraction()) for i in self.pair_breaks] + self.pair_stops
choices, locations, self.groove_report = generator.comping_rhythm.choose_cycles(self.groups, part, self.melody, seed, breaks=breaks) choices, locations, self.groove_report = generator.comping_rhythm.choose_cycles(self.groups, part, self.melody, seed, breaks=breaks, composition=self.composition)
self.patterns = {g[0][2]: (p, a) for g, (p, a, _) in zip(self.groups, choices)} self.patterns = {g[0][2]: (p, a) for g, (p, a, _) in zip(self.groups, choices)}
self.locations = {g[0][2]: location for g, location in zip(self.groups, locations)} self.locations = {g[0][2]: location for g, location in zip(self.groups, locations)}
selected = [r for _, _, r in choices] selected = [r for _, _, r in choices]
@@ -266,7 +262,7 @@ class Canonical:
melody.extend((a + cursor, b + cursor) for a, b in self.melody[i]) melody.extend((a + cursor, b + cursor) for a, b in self.melody[i])
lengths.append(generator.model.fraction(m['duration'])) lengths.append(generator.model.fraction(m['duration']))
cursor += lengths[-1] cursor += lengths[-1]
return generator.comping_rhythm.candidates(flat, None, self.part, tuple(melody), self.seed + '/' + str(inputs[0][2]), lengths=tuple(lengths), limit=limit) return generator.comping_rhythm.candidates(flat, None, self.part, tuple(melody), self.seed + '/' + str(inputs[0][2]), lengths=tuple(lengths), limit=limit, composition=self.composition)
def acoustic_realizations(self, sources, bounds=None, stop=None): def acoustic_realizations(self, sources, bounds=None, stop=None):
"""Assign the canonical attack stream once, before splitting it into blocks. """Assign the canonical attack stream once, before splitting it into blocks.
@@ -313,14 +309,14 @@ class Canonical:
offset, index = next((a, i) for a, i in reversed(offsets) if a <= start) offset, index = next((a, i) for a, i in reversed(offsets) if a <= start)
events = tuple(e for e in rhythm.events if start <= (e.harmonic_time if e.harmonic_time is not None else e.start) < end) events = tuple(e for e in rhythm.events if start <= (e.harmonic_time if e.harmonic_time is not None else e.start) < end)
try: try:
options = voicings(semantic_key(chord), self.part) options = voicings(semantic_key(chord), self.part, composition=self.composition)
except ValueError as error: except ValueError as error:
raise ValueError(f'source measure {inputs[index - inputs[0][2]][0]["number"]}, beat {start - offset + 1}: {error}') from error raise ValueError(f'source measure {inputs[index - inputs[0][2]][0]["number"]}, beat {start - offset + 1}: {error}') from error
spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options)) spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options))
return tuple(spans) return tuple(spans)
def select(self, spans, *, left=(), right=(), close=False): def select(self, spans, *, left=(), right=(), close=False):
layers = tuple(s.options for s in spans) layers = tuple(s.options for s in spans)
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer} for s, layer in zip(spans, layers)) costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55), composition=self.composition) for v in layer} for s, layer in zip(spans, layers))
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close) return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close)
def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None): def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None):
group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),)) group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
@@ -397,8 +393,8 @@ class Canonical:
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter'])) root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
rows.append({'source_index': span.index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord), rows.append({'source_index': span.index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
'figure': span.figure, 'pattern_id': rhythm.pattern_id, 'tones': len(grip), 'bass': bool(bass), 'variant': variant, 'figure': span.figure, 'pattern_id': rhythm.pattern_id, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
'family': (generator.chord_families.acoustic(span.chord, grip) if self.part == 'acoustic' else generator.chord_families.electric(grip)) if grip else None, 'family': (generator.chord_families.acoustic(span.chord, grip, composition=self.composition) if self.part == 'acoustic' else generator.chord_families.electric(grip)) if grip else None,
'family_coverage': generator.chord_families.coverage(span.chord, span.options, self.part), 'family_coverage': generator.chord_families.coverage(span.chord, span.options, self.part, composition=self.composition),
'fret_span': generator.fretboard.fret_span(grip), 'fret_span': generator.fretboard.fret_span(grip),
'span_group': ('fallback' if generator.fretboard.span_penalty(grip) else 'primary') if grip else None, 'span_group': ('fallback' if generator.fretboard.span_penalty(grip) else 'primary') if grip else None,
'span_candidates': generator.chord_families.span_coverage(span.options), 'span_candidates': generator.chord_families.span_coverage(span.options),
@@ -407,9 +403,9 @@ class Canonical:
'bass_string': bass.string_number if bass else None, 'bass_string': bass.string_number if bass else None,
'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)}) 'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)})
if grip: if grip:
register, position = voicing_preferences(self.part, grip, self.policy.get('center', 55)) register, position = voicing_preferences(self.part, grip, self.policy.get('center', 55), composition=self.composition)
travel = movement(self.part, previous, grip) travel = movement(self.part, previous, grip)
effort = generator.guitar.voicing_cost(grip, 'electric').effort if self.part == 'electric' else 0 effort = generator.guitar.voicing_cost(grip, 'electric', composition=self.composition).effort if self.part == 'electric' else 0
rows[-1]['voicing_cost'] = {'movement': travel, 'grip_effort': str(effort), 'register': str(register), 'position': str(position), 'combined': str(travel + effort + register + position)} rows[-1]['voicing_cost'] = {'movement': travel, 'grip_effort': str(effort), 'register': str(register), 'position': str(position), 'combined': str(travel + effort + register + position)}
for event in span.events: for event in span.events:
lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane] lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
+13 -10
View File
@@ -5,8 +5,8 @@ import itertools
import generator.accompaniment import generator.accompaniment
import generator.model import generator.model
import generator.solo.analysis
import generator.solo.policy import generator.solo.policy
import generator.source
import generator.vocabulary import generator.vocabulary
def meter_context(measure): def meter_context(measure):
@@ -90,7 +90,7 @@ class Rhythm:
return tuple((e.lane, str(e.start), str(e.end - e.start), str(e.harmonic_time)) for e in self.events) return tuple((e.lane, str(e.start), str(e.end - e.start), str(e.harmonic_time)) for e in self.events)
def melody_contexts(document): def melody_contexts(document):
melody = generator.solo.analysis.attacks(document) melody = generator.source.attacks(document)
result, cursor = [], fractions.Fraction() result, cursor = [], fractions.Fraction()
for m in document['staves'][0]['measures']: for m in document['staves'][0]['measures']:
length = generator.model.fraction(m['duration']) length = generator.model.fraction(m['duration'])
@@ -247,9 +247,10 @@ def evaluate(events, part, melody, harmony, lengths):
divisor = max(1, len(lengths)) divisor = max(1, len(lengths))
return tuple((k, v / divisor) for k, v in (('activity', activity), ('melody_support', support), ('harmonic_support', coverage), ('articulation', articulation))) return tuple((k, v / divisor) for k, v in (('activity', activity), ('melody_support', support), ('harmonic_support', coverage), ('articulation', articulation)))
def candidates(measure, prior, part, melody, seed, *, lengths=None, limit=None, grouping=()): def candidates(measure, prior, part, melody, seed, *, lengths=None, limit=None, grouping=(), composition=None):
composition = composition or generator.vocabulary.current()
lengths = lengths or (generator.model.fraction(measure['duration']),) lengths = lengths or (generator.model.fraction(measure['duration']),)
policy = generator.vocabulary.accompaniment(part) policy = composition.accompaniment(part)
result = [] result = []
for pattern in patterns_for(policy, measure, lengths, grouping): for pattern in patterns_for(policy, measure, lengths, grouping):
for anticipate in (False, True): for anticipate in (False, True):
@@ -371,13 +372,14 @@ def realize_acoustic_form(rhythms, context):
def form_cost(rhythms, groups): def form_cost(rhythms, groups):
return sum(r.cost * len(g) for r, g in zip(rhythms, groups)) return sum(r.cost * len(g) for r, g in zip(rhythms, groups))
def choose_cycles(groups, part, melody, seed, *, breaks=()): def choose_cycles(groups, part, melody, seed, *, breaks=(), composition=None):
"""Select a small recurring vocabulary against every occurrence in the form. """Select a small recurring vocabulary against every occurrence in the form.
No local shortlist precedes whole-form evaluation. A pattern/mode is retained No local shortlist precedes whole-form evaluation. A pattern/mode is retained
as one decision even where a chord boundary changes its legal release. as one decision even where a chord boundary changes its legal release.
""" """
policy = generator.vocabulary.accompaniment(part) composition = composition or generator.vocabulary.current()
policy = composition.accompaniment(part)
settings = policy['groove'] settings = policy['groove']
selected, locations, reports = [], [], [] selected, locations, reports = [], [], []
runs = [] runs = []
@@ -463,7 +465,7 @@ def choose_cycles(groups, part, melody, seed, *, breaks=()):
selected.append((p, anticipate, cache[i, p['id'], anticipate])) selected.append((p, anticipate, cache[i, p['id'], anticipate]))
locations.append({'cycle_start': run[0][0][2], 'cycle_slot': i % len(cycle), 'base_pattern': p['id'], 'fill': None}) locations.append({'cycle_start': run[0][0][2], 'cycle_slot': i % len(cycle), 'base_pattern': p['id'], 'fill': None})
context = acoustic_context(groups, melody, breaks) if part == 'acoustic' else None context = acoustic_context(groups, melody, breaks) if part == 'acoustic' else None
add_fills(groups, selected, locations, part, melody, seed, context=context) add_fills(groups, selected, locations, part, melody, seed, context=context, composition=composition)
if context: if context:
realized = realize_acoustic_form([r for _, _, r in selected], context) realized = realize_acoustic_form([r for _, _, r in selected], context)
selected = [(p, a, r) for (p, a, _), r in zip(selected, realized)] selected = [(p, a, r) for (p, a, _), r in zip(selected, realized)]
@@ -472,9 +474,10 @@ def choose_cycles(groups, part, melody, seed, *, breaks=()):
report['attack_assignment'] = 'sequential_combined_except_eighth_pairs' report['attack_assignment'] = 'sequential_combined_except_eighth_pairs'
return selected, locations, reports return selected, locations, reports
def add_fills(groups, selected, locations, part, melody, seed, *, context=None): def add_fills(groups, selected, locations, part, melody, seed, *, context=None, composition=None):
"""Fixed source-position answers; no iteration seed or generated lead input.""" """Fixed source-position answers; no iteration seed or generated lead input."""
policy = generator.vocabulary.accompaniment(part) composition = composition or generator.vocabulary.current()
policy = composition.accompaniment(part)
spacing = policy['groove']['fill_spacing_measures'] spacing = policy['groove']['fill_spacing_measures']
if not groups: if not groups:
return return
@@ -519,7 +522,7 @@ def add_fills(groups, selected, locations, part, melody, seed, *, context=None):
if any(e.start < middle < e.end for e in current.events): if any(e.start < middle < e.end for e in current.events):
continue continue
for variation in policy['patterns']: for variation in policy['patterns']:
if variation['family'] != base['family'] or variation['lengths'] != base['lengths'] or variation.get('meter') != base.get('meter') or variation.get('grouping') != base.get('grouping'): if variation['family'] != base['family'] or tuple(variation['lengths']) != tuple(base['lengths']) or variation.get('meter') != base.get('meter') or tuple(variation.get('grouping', ())) != tuple(base.get('grouping', ())):
continue continue
hybrid = {**base, 'id': base['id'] + '.fill.' + variation['id'], 'events': hybrid = {**base, 'id': base['id'] + '.fill.' + variation['id'], 'events':
[r for r in base['events'] if fractions.Fraction(r['onset']) < middle] + [r for r in base['events'] if fractions.Fraction(r['onset']) < middle] +
+8 -5
View File
@@ -1,24 +1,27 @@
"""Explicit single-role build. Does not publish or consume an existing ensemble.""" """Explicit single-role build. Does not publish or consume an existing ensemble."""
import argparse import argparse
import json import json
import time
import pathlib import pathlib
import time
import generator.composition
import generator.composition_types
import generator.musicxml import generator.musicxml
import generator.parts import generator.parts
import generator.production import generator.source
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--song', type=pathlib.Path, required=True) parser.add_argument('--song', type=pathlib.Path, required=True)
parser.add_argument('--role', choices=generator.production.ROLES, required=True) parser.add_argument('--role', choices=generator.composition_types.ROLES, required=True)
parser.add_argument('--data-dir', type=pathlib.Path, default=pathlib.Path('data')) parser.add_argument('--data-dir', type=pathlib.Path, default=pathlib.Path('data'))
parser.add_argument('--output', type=pathlib.Path, required=True) parser.add_argument('--output', type=pathlib.Path, required=True)
parser.add_argument('--cache-dir', type=pathlib.Path) parser.add_argument('--cache-dir', type=pathlib.Path)
parser.add_argument('--settings', type=pathlib.Path) parser.add_argument('--settings', type=pathlib.Path)
args = parser.parse_args() args = parser.parse_args()
source = generator.production.SourceAnalysis.read(generator.parts.prepare_document(generator.musicxml.load_musicxml(args.song))) source = generator.source.SourceAnalysis.read(generator.parts.prepare_document(generator.musicxml.load_musicxml(args.song)))
start = time.monotonic() 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) result = generator.composition.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.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, indent=2)) args.output.write_text(json.dumps(result, indent=2))
print(f'{args.role}: {time.monotonic() - start:.2f}s wall; cached={result["cache_hit"]}; role generation/cache lookup {result["seconds"]:.2f}s') print(f'{args.role}: {time.monotonic() - start:.2f}s wall; cached={result["cache_hit"]}; role generation/cache lookup {result["seconds"]:.2f}s')
+158
View File
@@ -0,0 +1,158 @@
"""Independent, reproducible role generation from source and policy inputs.
Only source documents and production resources enter generate_role. Completed
roles and published assets are downstream values, never composition inputs.
"""
from __future__ import annotations
import copy
import dataclasses
import functools
import hashlib
import json
import pathlib
import time
import generator.accompaniment
import generator.acoustic
import generator.arrange.assembly
import generator.arrange.form
import generator.arrange.generate
import generator.comping
import generator.comping_rhythm
import generator.composition_types
import generator.fretboard
import generator.input_audit
import generator.role_cache
import generator.score
import generator.solo.arrangement
import generator.solo.authored
import generator.solo.composer
import generator.solo.learned
import generator.solo.notation
import generator.solo.report
import generator.source
import generator.validation
import generator.vocabulary
VERSION = 'independent-roles-1'
def dependencies(role, resources, measures=None):
names = ['harmony', 'lead_ins'] if role.endswith('_solo') else []
accompaniment = {}
if role.endswith('_accompaniment'):
policy = generator.vocabulary.accompaniment(role.split('_')[0], resources)
accompaniment = {'accompaniment': generator.comping_rhythm.relevant_policy(policy, measures) if measures is not None else policy}
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
**({'voicing_families': generator.vocabulary.accompaniment('electric', resources)['voicing_families']} if role == 'electric_melody' else {}),
**accompaniment,
**{name: resources[name] for name in names}}
def _resource_key(directory, role):
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 generator.solo.learned.Model.load(pathlib.Path(directory) / 'models' / 'solo.json')
def generate_role(source, role, data_dir, settings=None, cache_dir=None) -> generator.composition_types.RoleResult:
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
return result
def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
if role not in generator.composition_types.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, generator.score.source_staff(source.document())['measures']), 'catalogs': ({'authored': registered['sha256'], 'id': registered['id'], 'pitch_convention': registered['pitch_convention']} if registered else _resource_key(data_dir, role)), 'code': generator.role_cache.code_key()}
# Source notation/lyrics also invalidate cached score representation, never the musical seed.
key = generator.vocabulary.fingerprint({'inputs': inputs, 'notation': source.document_json})
path = pathlib.Path(cache_dir) / role / (key + '.json') if cache_dir else None
cached = generator.role_cache.restore(path, key)
if cached is not None:
return cached
started = time.monotonic()
document = source.document()
part = role.split('_')[0]
plan = generator.arrange.form.FormPlan.read(document, part)
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}
choice_source = source.accompaniment_key() if role.endswith('_accompaniment') else source.musical_key
choice_data, choice_settings = inputs['vocabulary'], settings
if role.endswith('_accompaniment'):
# Voicing weights invalidate the concrete cache, but must not reshuffle
# rhythm patterns. Removing this new field preserves the pre-policy seed.
choice_data = copy.deepcopy(choice_data)
choice_data['roles']['role'].pop('voicing', None)
for field in ('provenance', 'license', 'research'):
choice_data['accompaniment'].pop(field, None)
choice_settings = {k: v for k, v in settings.items() if k != 'voicing'}
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': choice_settings, 'data': choice_data, 'version': algorithm})
details = {}
solo_report = ''
composition_timings = {}
composition = generator.vocabulary.CompositionContext.create(resources, role, settings | {'choice_seed': seed})
if role.endswith('_solo'):
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'slash'}, composition=composition)
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 = generator.score.source_staff(document)['measures']
section = plan.sections[si]
effective = generator.arrange.generate.section_source(authored, section, plan.sections[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 plan.windows 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 = generator.accompaniment.carried_chords(result['staves'][0]['measures'])[first - 1]
if registered:
solo = generator.solo.authored.realize(registered, generator.source.analyze(document), template, part, start, end, initial, composition=composition)
else:
solo_model = _solo_model(str(data_dir), inputs['catalogs']['model'])
solo = generator.solo.composer.compose(generator.source.analyze(document), template, part, end, initial=initial, learned_model=solo_model, expressions_enabled=composition.role().get('expressions', True), start=start, composition=composition)
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, composition=composition)
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, composition=composition)
elif role.endswith('_accompaniment'):
canonical = generator.comping.Canonical(document, part, seed, composition=composition, plan=plan)
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'accompaniment'}, accompaniment=canonical, composition=composition)
details = {'canonical_comping': canonical.report, 'accompaniment_rhythm': canonical.rhythm_report, 'groove_cycles': canonical.groove_report, 'rhythm_source': choice_source}
else:
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'vocal' if part == 'vocal' else 'melody'}, composition=composition)
allowed = tuple((a, b) for a, b, r in plan.windows if r == role)
generator.arrange.assembly.mask(result, allowed)
if role == "acoustic_accompaniment":
generator.acoustic.validate_comping(result)
staff = result['staves'][0]
for measure in staff['measures']:
measure['id'] = role + '_' + measure['id']
staff['tuplets'] = generator.score.reidentify(staff['measures'], staff.get('tuplets', []))
if part != 'vocal':
result['staves'][0]['tablature'] = True
generator.validation.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))
generator.role_cache.store(path, key, value)
return value
+28
View File
@@ -0,0 +1,28 @@
"""Worker requests and role results; score serialization stays unchanged."""
from __future__ import annotations
import dataclasses
import pathlib
import typing
import generator.source
ROLES = ('acoustic_accompaniment', 'electric_accompaniment', 'electric_melody', 'vocal_melody', 'acoustic_solo', 'electric_solo')
@dataclasses.dataclass(frozen=True)
class RoleRequest:
source: generator.source.SourceAnalysis
role: str
directory: pathlib.Path
settings_json: str = '{}'
cache: pathlib.Path | None = None
class RoleResult(typing.TypedDict):
role: str
document: dict
trace: dict
details: dict
report: str
seconds: float
composition_timings: dict
cache_hit: bool
observed_reads: typing.NotRequired[list[str]]
+10 -34
View File
@@ -9,12 +9,12 @@ import pathlib
import platform import platform
import re import re
import shutil import shutil
import tempfile
import PIL.Image import PIL.Image
import PIL.ImageChops import PIL.ImageChops
import PIL.features import PIL.features
import generator.cache
import generator.engraving.validation import generator.engraving.validation
import generator.theme import generator.theme
@@ -22,9 +22,10 @@ def renderer_identity(fonts, style) -> dict:
root = pathlib.Path(__file__).resolve().parents[1] root = pathlib.Path(__file__).resolve().parents[1]
versions = {name: importlib.metadata.version(name) for name in ('Pillow', 'fonttools', 'aggdraw')} 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()}) versions.update({name: PIL.features.version(name) for name in PIL.features.get_supported()})
sources = [*root.joinpath('engraving').glob('*.py'), root / 'model.py', root / 'performance.py', root / 'harmony.py', root / 'theme.py', root / 'palette.json'] sources = generator.cache.source_identity(('generator.engraving.pipeline', 'generator.printing'))
sources['palette.json'] = hashlib.sha256((root / 'palette.json').read_bytes()).hexdigest()
return {'fonts_style': fonts.fingerprint(style), 'python': {'implementation': platform.python_implementation(), 'version': platform.python_version()}, 'dependencies': versions, 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)}} 'sources': sources}
def renderer_fingerprint(fonts, style) -> str: def renderer_fingerprint(fonts, style) -> str:
return hashlib.sha256(json.dumps(renderer_identity(fonts, style), sort_keys=True).encode()).hexdigest() return hashlib.sha256(json.dumps(renderer_identity(fonts, style), sort_keys=True).encode()).hexdigest()
@@ -46,12 +47,9 @@ def band_fingerprint(notation: str, page, band) -> str:
context = [notation, page.width, page.height, page.supersampling, dataclasses.asdict(band)] context = [notation, page.width, page.height, page.supersampling, dataclasses.asdict(band)]
return hashlib.sha256(json.dumps(context, sort_keys=True, ensure_ascii=False).encode()).hexdigest() return hashlib.sha256(json.dumps(context, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
def has_symlink(path: pathlib.Path) -> bool:
return any(parent.is_symlink() for parent in (path, *path.parents))
def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_pages: set[str] | None = None) -> list[dict] | None: def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_pages: set[str] | None = None) -> list[dict] | None:
try: try:
if has_symlink(entry) or has_symlink(destination): if generator.cache.has_symlink(entry) or generator.cache.has_symlink(destination):
return None return None
manifest = entry / 'pages.json' manifest = entry / 'pages.json'
if manifest.is_symlink(): if manifest.is_symlink():
@@ -76,28 +74,17 @@ def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_page
return None return None
def store_pages(entry: pathlib.Path, destination: pathlib.Path, pages: list[dict]) -> None: def store_pages(entry: pathlib.Path, destination: pathlib.Path, pages: list[dict]) -> None:
if has_symlink(entry.parent): with generator.cache.stage_entry(entry, '.engraving_') as staged:
raise ValueError('engraving cache directories must not be symlinks')
entry.parent.mkdir(parents=True, exist_ok=True)
if entry.is_symlink() or entry.is_file():
entry.unlink(missing_ok=True)
entry.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.engraving_', dir=entry.parent) as temporary:
staged = pathlib.Path(temporary)
for page in pages: for page in pages:
shutil.copyfile(destination / page['path'], staged / page['path']) shutil.copyfile(destination / page['path'], staged / page['path'])
(staged / 'pages.json').write_text(json.dumps(pages), encoding='utf-8') (staged / 'pages.json').write_text(json.dumps(pages), encoding='utf-8')
# Identical keys have identical pages; publish complete files, then the manifest. # Identical keys have identical pages; publish complete files, then the manifest.
for name in list(dict.fromkeys(page['path'] for page in pages)) + ['pages.json']: generator.cache.publish_files(entry, staged, list(dict.fromkeys(page['path'] for page in pages)) + ['pages.json'])
target = entry / name
if target.is_dir() and not target.is_symlink():
shutil.rmtree(target, ignore_errors=True)
(staged / name).replace(target)
def restore_band(entry: pathlib.Path, key: str, width: int, height: int) -> PIL.Image.Image | None: def restore_band(entry: pathlib.Path, key: str, width: int, height: int) -> PIL.Image.Image | None:
try: try:
manifest, asset = entry / 'band.json', entry / 'band.png' manifest, asset = entry / 'band.json', entry / 'band.png'
if has_symlink(entry) or manifest.is_symlink() or asset.is_symlink(): if generator.cache.has_symlink(entry) or manifest.is_symlink() or asset.is_symlink():
return None return None
metadata = json.loads(manifest.read_text(encoding='utf-8')) metadata = json.loads(manifest.read_text(encoding='utf-8'))
if not isinstance(metadata, dict) or set(metadata) != {'context', 'width', 'height', 'sha256'}: if not isinstance(metadata, dict) or set(metadata) != {'context', 'width', 'height', 'sha256'}:
@@ -122,19 +109,8 @@ def restore_band(entry: pathlib.Path, key: str, width: int, height: int) -> PIL.
return None return None
def store_band(entry: pathlib.Path, key: str, image: PIL.Image.Image) -> None: def store_band(entry: pathlib.Path, key: str, image: PIL.Image.Image) -> None:
if has_symlink(entry.parent): with generator.cache.stage_entry(entry, '.engraving_') as staged:
raise ValueError('engraving cache directories must not be symlinks')
entry.parent.mkdir(parents=True, exist_ok=True)
if entry.is_symlink() or entry.is_file():
entry.unlink()
entry.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.engraving_', dir=entry.parent) as temporary:
staged = pathlib.Path(temporary)
image.save(staged / 'band.png', format='PNG') image.save(staged / 'band.png', format='PNG')
metadata = {'context': key, 'width': image.width, 'height': image.height, 'sha256': hashlib.sha256((staged / 'band.png').read_bytes()).hexdigest()} metadata = {'context': key, 'width': image.width, 'height': image.height, 'sha256': hashlib.sha256((staged / 'band.png').read_bytes()).hexdigest()}
(staged / 'band.json').write_text(json.dumps(metadata), encoding='utf-8') (staged / 'band.json').write_text(json.dumps(metadata), encoding='utf-8')
for name in ('band.png', 'band.json'): generator.cache.publish_files(entry, staged, ('band.png', 'band.json'))
target = entry / name
if target.is_dir() and not target.is_symlink():
shutil.rmtree(target)
(staged / name).replace(target)
+3 -3
View File
@@ -16,7 +16,7 @@ import generator.engraving.spans
import generator.engraving.tablature import generator.engraving.tablature
import generator.engraving.types import generator.engraving.types
import generator.harmony import generator.harmony
import generator.model import generator.validation
def lyric_text(verse: dict) -> str: def lyric_text(verse: dict) -> str:
return ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision')) return ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
@@ -250,7 +250,7 @@ def paginate(systems, document, fonts, style, *, index=None):
def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts, *, index=None) -> tuple[generator.engraving.types.System, ...]: def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts, *, index=None) -> tuple[generator.engraving.types.System, ...]:
if index is None: if index is None:
generator.model.validate_document(document) generator.validation.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document) index = generator.engraving.notation.ScoreIndex(document)
if not index.measures: if not index.measures:
raise generator.engraving.types.EngravingError('score has no measures') raise generator.engraving.types.EngravingError('score has no measures')
@@ -276,7 +276,7 @@ def layout_systems(document: dict, style: generator.engraving.types.Style, fonts
return tuple(systems) return tuple(systems)
def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument: def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument:
generator.model.validate_document(document) generator.validation.validate_document(document)
style = generator.engraving.types.score_style(document, style) style = generator.engraving.types.score_style(document, style)
index = generator.engraving.notation.ScoreIndex(document) index = generator.engraving.notation.ScoreIndex(document)
systems = layout_systems(document, style, fonts, index=index) systems = layout_systems(document, style, fonts, index=index)
+9 -187
View File
@@ -1,195 +1,17 @@
from __future__ import annotations from __future__ import annotations
import collections
import concurrent.futures import concurrent.futures
import dataclasses
import hashlib
import json import json
import multiprocessing import multiprocessing
import pathlib import pathlib
import shutil
import tempfile
import time import time
import PIL.Image
import generator.engraving.audit
import generator.engraving.cache import generator.engraving.cache
import generator.engraving.fonts import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.notation
import generator.engraving.raster
import generator.engraving.types import generator.engraving.types
import generator.engraving.validation import generator.engraving.validation
import generator.model import generator.engraving.worker
import generator.performance import generator.validation
@dataclasses.dataclass
class GroupResult:
scores: list = dataclasses.field(default_factory=list)
fullscore: int = 0
bodyhits: int = 0
bodies: int = 0
renderedpages: int = 0
rejected: int = 0
timings: dict = dataclasses.field(default_factory=lambda: dict.fromkeys(('layout', 'pagination', 'raster', 'cache'), 0.0))
class BodyBands:
def __init__(self, root, persistent, result, limit=16 * 1024 * 1024):
self.root, self.persistent, self.result, self.limit = root, persistent, result, limit
self.images = collections.OrderedDict()
self.size = 0
self.known = {}
self.root.mkdir(parents=True, exist_ok=True)
def get(self, notation, page, band, fonts):
key = generator.engraving.cache.band_fingerprint(notation, page, band)
if key in self.images:
self.images.move_to_end(key)
self.result.bodyhits += 1
return self.images[key]
started = time.monotonic()
entry = self.root / (key + '.rgb')
image = None
if key in self.known:
if entry.is_symlink() or entry.stat().st_size != page.width * (band.bottom - band.top) * 3:
raise generator.engraving.types.EngravingError('temporary notation band was corrupted')
data = entry.read_bytes()
if hashlib.sha256(data).hexdigest() != self.known[key]:
raise generator.engraving.types.EngravingError('temporary notation band was corrupted')
image = PIL.Image.frombytes('RGB', (page.width, band.bottom - band.top), data)
elif self.persistent:
cached = self.persistent / 'notation' / key
image = generator.engraving.cache.restore_band(cached, key, page.width, band.bottom - band.top)
self.result.rejected += int(image is None and (cached.exists() or cached.is_symlink()))
self.result.timings['cache'] += time.monotonic() - started
if image is None:
started = time.monotonic()
image = generator.engraving.raster.rasterize_band(page, band.primitives, band.top, band.bottom, fonts)
self.result.timings['raster'] += time.monotonic() - started
self.result.bodies += 1
if self.persistent:
started = time.monotonic()
generator.engraving.cache.store_band(self.persistent / 'notation' / key, key, image)
self.result.timings['cache'] += time.monotonic() - started
else:
self.result.bodyhits += 1
if key not in self.known:
started = time.monotonic()
# Spill finalized pixels, so eviction never repeats rasterization or PNG decoding.
data = image.tobytes()
entry.write_bytes(data)
self.result.timings['cache'] += time.monotonic() - started
self.known[key] = hashlib.sha256(data).hexdigest()
size = image.width * image.height * 3
while self.images and self.size + size > self.limit:
_, old = self.images.popitem(last=False)
self.size -= old.width * old.height * 3
old.close()
self.images[key] = image
self.size += size
return image
def close(self):
for image in self.images.values():
image.close()
self.images.clear()
self.size = 0
def clear_pages(destination):
if destination.is_symlink() or destination.is_file():
destination.unlink()
elif destination.exists():
shutil.rmtree(destination)
def write_bundle(path, document, pages, fingerprint):
entries = [{k: v for k, v in {**page, 'path': path.with_suffix('.pages').name + '/' + page['path']}.items() if k != 'playback'} for page in pages]
normal = [entry for entry, page in zip(entries, pages) if not page['playback']]
playback = [entry for entry, page in zip(entries, pages) if page['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': normal, 'playback_pages': playback}}
validate_cached_views(document, pages)
bundle['performance'] = generator.performance.timing(document)
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def validate_cached_views(document, pages):
generator.performance.validate_views(document, [p for p in pages if not p['playback']], [p for p in pages if p['playback']])
def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False):
family = generator.engraving.fonts.Fonts(font_path)
style = generator.engraving.types.Style()
systems = None
index = None
scores = {}
audited = set()
result = GroupResult()
with tempfile.TemporaryDirectory(prefix='vamp_notation_') as temporary:
bodies = BodyBands(pathlib.Path(temporary), cache_dir, result)
try:
for path, score_key in paths:
try:
document = json.loads(path.read_text(encoding='utf-8'))
style = generator.engraving.types.score_style(document, generator.engraving.types.Style())
destination = path.with_suffix('.pages')
clear_pages(destination)
destination.mkdir()
if score_key in scores:
source, pages = scores[score_key]
for page in pages:
shutil.copyfile(source / page['path'], destination / page['path'])
result.fullscore += 1
else:
if systems is None:
started = time.monotonic()
generator.model.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
systems = generator.engraving.layout.layout_systems(document, style, family, index=index)
result.timings['layout'] += time.monotonic() - started
started = time.monotonic()
pages_layout, playback_layout = generator.engraving.layout.page_views(systems, document, family, style, index=index)
placement = tuple((page.width, page.height, tuple((system.start, system.end, system.box) for system in page.systems)) for page in pages_layout)
if placement not in audited:
layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint)
generator.engraving.audit.audit_layout(document, layout, family)
generator.engraving.audit.audit_playback(pages_layout, playback_layout, style, family)
audited.add(placement)
else:
for page in pages_layout + playback_layout:
decoration = tuple(p for p in page.primitives if p.role in ('title', 'subtitle', 'page_number'))
generator.engraving.audit.audit_primitives(decoration, page.width, page.height, family)
result.timings['pagination'] += time.monotonic() - started
pages = []
rendered = {}
for is_playback, page in [(False, p) for p in pages_layout] + [(True, p) for p in playback_layout]:
if page.primitives in rendered:
name, digest = rendered[page.primitives]
pages.append(dict(path=name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
continue
bands = generator.engraving.raster.page_bands(page)
if bands is None:
started = time.monotonic()
image = generator.engraving.raster.rasterize_page(page, family)
else:
body = bodies.get(notation, page, bands[1], family)
started = time.monotonic()
image = generator.engraving.raster.compose_page(page, family, bands, body)
target = destination / (('playback-' if is_playback else '') + f'{page.number:04}.png')
with image:
image.save(target, format='PNG')
result.timings['raster'] += time.monotonic() - started
result.renderedpages += 1
digest = hashlib.sha256(target.read_bytes()).hexdigest()
rendered[page.primitives] = target.name, digest
pages.append(dict(path=target.name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
scores[score_key] = (destination, pages)
write_bundle(path, document, pages, fingerprint)
result.scores.append((path, score_key, pages))
if progress and len(result.scores) % 50 == 0:
print(f'Engraving body {notation[:12]}: {len(result.scores)}/{len(paths)} scores, fullscore {result.fullscore}, bodyhits {result.bodyhits}, bodies {result.bodies}, renderedpages {result.renderedpages}', flush=True)
except ValueError as error:
raise generator.engraving.types.EngravingError(f'{path.relative_to(root)}: {error}') from error
finally:
bodies.close()
return result
def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.Path | None = None, jobs: int = 1, progress: bool = False) -> set[str]: def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.Path | None = None, jobs: int = 1, progress: bool = False) -> set[str]:
if type(jobs) is not int or not 1 <= jobs <= 16: if type(jobs) is not int or not 1 <= jobs <= 16:
@@ -211,22 +33,22 @@ def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.
print(f'Engraving cache enabled: {cache_dir} ({state}; renderer {renderer})', flush=True) print(f'Engraving cache enabled: {cache_dir} ({state}; renderer {renderer})', flush=True)
elif progress: elif progress:
print('Engraving cache disabled', flush=True) print('Engraving cache disabled', flush=True)
totals = GroupResult() totals = generator.engraving.worker.GroupResult()
missing, rejected, completed = 0, 0, 0 missing, rejected, completed = 0, 0, 0
groups = {} groups = {}
for path in paths: for path in paths:
try: try:
document = json.loads(path.read_text(encoding='utf-8')) document = json.loads(path.read_text(encoding='utf-8'))
generator.model.validate_document(document) generator.validation.validate_document(document)
score_key = generator.engraving.cache.score_fingerprint(document, renderer) score_key = generator.engraving.cache.score_fingerprint(document, renderer)
destination = path.with_suffix('.pages') destination = path.with_suffix('.pages')
clear_pages(destination) generator.engraving.worker.clear_pages(destination)
entry = cache_dir / 'fullscore' / score_key if cache_dir else None entry = cache_dir / 'fullscore' / score_key if cache_dir else None
present = entry is not None and (entry.exists() or entry.is_symlink()) 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 pages = generator.engraving.cache.restore_pages(entry, destination, validated_pages) if entry else None
if pages is not None: if pages is not None:
try: try:
validate_cached_views(document, pages) generator.engraving.worker.validate_cached_views(document, pages)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):
pages = None pages = None
if pages is None: if pages is None:
@@ -235,7 +57,7 @@ def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.
notation = generator.engraving.cache.notation_fingerprint(document, renderer) notation = generator.engraving.cache.notation_fingerprint(document, renderer)
groups.setdefault(notation, []).append((path, score_key)) groups.setdefault(notation, []).append((path, score_key))
else: else:
write_bundle(path, document, pages, fingerprint) generator.engraving.worker.write_bundle(path, document, pages, fingerprint)
totals.fullscore += 1 totals.fullscore += 1
completed += 1 completed += 1
except ValueError as error: except ValueError as error:
@@ -272,10 +94,10 @@ def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.
jobs = min(jobs, len(groups)) jobs = min(jobs, len(groups))
if jobs <= 1: if jobs <= 1:
for notation, group in groups.items(): for notation, group in groups.items():
finish(engrave_group(notation, group, root, fonts, cache_dir, fingerprint, progress)) finish(generator.engraving.worker.engrave_group(notation, group, root, fonts, cache_dir, fingerprint, progress))
else: else:
with concurrent.futures.ProcessPoolExecutor(max_workers=jobs, mp_context=multiprocessing.get_context('spawn')) as pool: with concurrent.futures.ProcessPoolExecutor(max_workers=jobs, mp_context=multiprocessing.get_context('spawn')) as pool:
futures = [pool.submit(engrave_group, notation, group, root, fonts, cache_dir, fingerprint, progress) for notation, group in groups.items()] futures = [pool.submit(generator.engraving.worker.engrave_group, notation, group, root, fonts, cache_dir, fingerprint, progress) for notation, group in groups.items()]
for future in concurrent.futures.as_completed(futures): for future in concurrent.futures.as_completed(futures):
finish(future.result()) finish(future.result())
if progress: if progress:
+190
View File
@@ -0,0 +1,190 @@
from __future__ import annotations
import collections
import dataclasses
import hashlib
import json
import pathlib
import shutil
import tempfile
import time
import PIL.Image
import generator.engraving.audit
import generator.engraving.cache
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.notation
import generator.engraving.raster
import generator.engraving.types
import generator.engraving.validation
import generator.performance
import generator.validation
@dataclasses.dataclass
class GroupResult:
scores: list = dataclasses.field(default_factory=list)
fullscore: int = 0
bodyhits: int = 0
bodies: int = 0
renderedpages: int = 0
rejected: int = 0
timings: dict = dataclasses.field(default_factory=lambda: dict.fromkeys(('layout', 'pagination', 'raster', 'cache'), 0.0))
class BodyBands:
def __init__(self, root, persistent, result, limit=16 * 1024 * 1024):
self.root, self.persistent, self.result, self.limit = root, persistent, result, limit
self.images = collections.OrderedDict()
self.size = 0
self.known = {}
self.root.mkdir(parents=True, exist_ok=True)
def get(self, notation, page, band, fonts):
key = generator.engraving.cache.band_fingerprint(notation, page, band)
if key in self.images:
self.images.move_to_end(key)
self.result.bodyhits += 1
return self.images[key]
started = time.monotonic()
entry = self.root / (key + '.rgb')
image = None
if key in self.known:
if entry.is_symlink() or entry.stat().st_size != page.width * (band.bottom - band.top) * 3:
raise generator.engraving.types.EngravingError('temporary notation band was corrupted')
data = entry.read_bytes()
if hashlib.sha256(data).hexdigest() != self.known[key]:
raise generator.engraving.types.EngravingError('temporary notation band was corrupted')
image = PIL.Image.frombytes('RGB', (page.width, band.bottom - band.top), data)
elif self.persistent:
cached = self.persistent / 'notation' / key
image = generator.engraving.cache.restore_band(cached, key, page.width, band.bottom - band.top)
self.result.rejected += int(image is None and (cached.exists() or cached.is_symlink()))
self.result.timings['cache'] += time.monotonic() - started
if image is None:
started = time.monotonic()
image = generator.engraving.raster.rasterize_band(page, band.primitives, band.top, band.bottom, fonts)
self.result.timings['raster'] += time.monotonic() - started
self.result.bodies += 1
if self.persistent:
started = time.monotonic()
generator.engraving.cache.store_band(self.persistent / 'notation' / key, key, image)
self.result.timings['cache'] += time.monotonic() - started
else:
self.result.bodyhits += 1
if key not in self.known:
started = time.monotonic()
# Spill finalized pixels, so eviction never repeats rasterization or PNG decoding.
data = image.tobytes()
entry.write_bytes(data)
self.result.timings['cache'] += time.monotonic() - started
self.known[key] = hashlib.sha256(data).hexdigest()
size = image.width * image.height * 3
while self.images and self.size + size > self.limit:
_, old = self.images.popitem(last=False)
self.size -= old.width * old.height * 3
old.close()
self.images[key] = image
self.size += size
return image
def close(self):
for image in self.images.values():
image.close()
self.images.clear()
self.size = 0
def clear_pages(destination):
if destination.is_symlink() or destination.is_file():
destination.unlink()
elif destination.exists():
shutil.rmtree(destination)
def write_bundle(path, document, pages, fingerprint):
entries = [{k: v for k, v in {**page, 'path': path.with_suffix('.pages').name + '/' + page['path']}.items() if k != 'playback'} for page in pages]
normal = [entry for entry, page in zip(entries, pages) if not page['playback']]
playback = [entry for entry, page in zip(entries, pages) if page['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': normal, 'playback_pages': playback}}
validate_cached_views(document, pages)
bundle['performance'] = generator.performance.timing(document)
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def validate_cached_views(document, pages):
generator.performance.validate_views(document, [p for p in pages if not p['playback']], [p for p in pages if p['playback']])
def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False):
family = generator.engraving.fonts.Fonts(font_path)
style = generator.engraving.types.Style()
systems = None
index = None
scores = {}
audited = set()
result = GroupResult()
with tempfile.TemporaryDirectory(prefix='vamp_notation_') as temporary:
bodies = BodyBands(pathlib.Path(temporary), cache_dir, result)
try:
for path, score_key in paths:
try:
document = json.loads(path.read_text(encoding='utf-8'))
style = generator.engraving.types.score_style(document, generator.engraving.types.Style())
destination = path.with_suffix('.pages')
clear_pages(destination)
destination.mkdir()
if score_key in scores:
source, pages = scores[score_key]
for page in pages:
shutil.copyfile(source / page['path'], destination / page['path'])
result.fullscore += 1
else:
if systems is None:
started = time.monotonic()
generator.validation.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
systems = generator.engraving.layout.layout_systems(document, style, family, index=index)
result.timings['layout'] += time.monotonic() - started
started = time.monotonic()
pages_layout, playback_layout = generator.engraving.layout.page_views(systems, document, family, style, index=index)
placement = tuple((page.width, page.height, tuple((system.start, system.end, system.box) for system in page.systems)) for page in pages_layout)
if placement not in audited:
layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint)
generator.engraving.audit.audit_layout(document, layout, family)
generator.engraving.audit.audit_playback(pages_layout, playback_layout, style, family)
audited.add(placement)
else:
for page in pages_layout + playback_layout:
decoration = tuple(p for p in page.primitives if p.role in ('title', 'subtitle', 'page_number'))
generator.engraving.audit.audit_primitives(decoration, page.width, page.height, family)
result.timings['pagination'] += time.monotonic() - started
pages = []
rendered = {}
for is_playback, page in [(False, p) for p in pages_layout] + [(True, p) for p in playback_layout]:
if page.primitives in rendered:
name, digest = rendered[page.primitives]
pages.append(dict(path=name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
continue
bands = generator.engraving.raster.page_bands(page)
if bands is None:
started = time.monotonic()
image = generator.engraving.raster.rasterize_page(page, family)
else:
body = bodies.get(notation, page, bands[1], family)
started = time.monotonic()
image = generator.engraving.raster.compose_page(page, family, bands, body)
target = destination / (('playback-' if is_playback else '') + f'{page.number:04}.png')
with image:
image.save(target, format='PNG')
result.timings['raster'] += time.monotonic() - started
result.renderedpages += 1
digest = hashlib.sha256(target.read_bytes()).hexdigest()
rendered[page.primitives] = target.name, digest
pages.append(dict(path=target.name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
scores[score_key] = (destination, pages)
write_bundle(path, document, pages, fingerprint)
result.scores.append((path, score_key, pages))
if progress and len(result.scores) % 50 == 0:
print(f'Engraving body {notation[:12]}: {len(result.scores)}/{len(paths)} scores, fullscore {result.fullscore}, bodyhits {result.bodyhits}, bodies {result.bodies}, renderedpages {result.renderedpages}', flush=True)
except ValueError as error:
raise generator.engraving.types.EngravingError(f'{path.relative_to(root)}: {error}') from error
finally:
bodies.close()
return result
+25 -19
View File
@@ -7,10 +7,10 @@ import itertools
import logging import logging
import typing import typing
import generator.chord_families
import generator.fretboard import generator.fretboard
import generator.model import generator.model
import generator.pitch import generator.pitch
import generator.chord_families
import generator.vocabulary import generator.vocabulary
PREFERRED_MAX_FRET = 12 PREFERRED_MAX_FRET = 12
@@ -31,9 +31,10 @@ class VoicingCost(typing.NamedTuple):
def moved(self, distance: int) -> VoicingCost: def moved(self, distance: int) -> VoicingCost:
return self._replace(effort=self.effort + distance) return self._replace(effort=self.effort + distance)
def voicing_preference(role, center=None): def voicing_preference(role, center=None, *, composition=None):
"""Immutable role settings for both selection and scored-retrieval caches.""" """Immutable role settings for both selection and scored-retrieval caches."""
settings = generator.vocabulary.role(role) composition = composition or generator.vocabulary.current()
settings = composition.role(role)
policy = settings.get('voicing', {}) policy = settings.get('voicing', {})
center = settings.get('center', 53 if role.startswith('acoustic') else 60) if center is None else center center = settings.get('center', 53 if role.startswith('acoustic') else 60) if center is None else center
result = tuple(fractions.Fraction(str(v)) for v in (center, policy.get('register_weight', 1), policy.get('high_position_weight', 1), policy.get('comfortable_max_fret', 9))) result = tuple(fractions.Fraction(str(v)) for v in (center, policy.get('register_weight', 1), policy.get('high_position_weight', 1), policy.get('comfortable_max_fret', 9)))
@@ -50,9 +51,10 @@ def voicing_preferences(positions, preference):
position = max(fractions.Fraction(), fractions.Fraction(sum(p.fret for p in positions), len(positions)) - threshold) position = max(fractions.Fraction(), fractions.Fraction(sum(p.fret for p in positions), len(positions)) - threshold)
return register_weight * register, position_weight * position return register_weight * register, position_weight * position
def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = ()) -> VoicingCost: def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = (), *, composition=None) -> VoicingCost:
families = tuple(generator.vocabulary.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else () composition = composition or generator.vocabulary.current()
preference = voicing_preference('electric_melody') if profile == 'electric' and melody else () families = tuple(composition.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else ()
preference = voicing_preference('electric_melody', composition=composition) if profile == 'electric' and melody else ()
return _voicing_cost(positions, profile, allowed, melody, families, preference) return _voicing_cost(positions, profile, allowed, melody, families, preference)
@functools.lru_cache(maxsize=8192) @functools.lru_cache(maxsize=8192)
@@ -69,9 +71,10 @@ def _voicing_cost(positions, profile, allowed, melody, families, preference):
melody_string = min((p.string_number - 1 for p in positions if melody and p.midi == max(melody)), default=0) melody_string = min((p.string_number - 1 for p in positions if melody and p.midi == max(melody)), default=0)
return VoicingCost(extended_grips=generator.fretboard.span_penalty(positions), melody_string=melody_string, string_shortfall=max(0, 4 - len(positions)) if profile == 'electric' else 0, covered_melody=int(bool(above)), covering_tones=above, family_penalty=family_penalty, effort=effort, missing_tones=len(set(allowed) - classes)) return VoicingCost(extended_grips=generator.fretboard.span_penalty(positions), melody_string=melody_string, string_shortfall=max(0, 4 - len(positions)) if profile == 'electric' else 0, covered_melody=int(bool(above)), covering_tones=above, family_penalty=family_penalty, effort=effort, missing_tones=len(set(allowed) - classes))
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]: def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = (), *, composition=None) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]:
families = tuple(generator.vocabulary.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else () composition = composition or generator.vocabulary.current()
preference = voicing_preference('electric_melody') if profile == 'electric' and melody else () families = tuple(composition.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else ()
preference = voicing_preference('electric_melody', composition=composition) if profile == 'electric' and melody else ()
return _chord_positions(requirements, prune, profile, melody, families, preference) return _chord_positions(requirements, prune, profile, melody, families, preference)
@functools.lru_cache(maxsize=256) @functools.lru_cache(maxsize=256)
@@ -211,7 +214,8 @@ def continued_sources(notes):
def has_attack(notes: list[dict]) -> bool: def has_attack(notes: list[dict]) -> bool:
return any('_harmony' in n and '_continue_from' not in n or any('tie_from' not in p for p in n['pitches']) for n in notes) return any('_harmony' in n and '_continue_from' not in n or any('tie_from' not in p for p in n['pitches']) for n in notes)
def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool, fallback: bool = False): def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool, fallback: bool = False, *, composition=None):
composition = composition or generator.vocabulary.current()
pending = [] pending = []
chords = [n for n in notes if '_harmony' in n] chords = [n for n in notes if '_harmony' in n]
chord = chords[0] if chords else None chord = chords[0] if chords else None
@@ -229,7 +233,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
requirements = generator.fretboard.chord_requirements(chord['_harmony'], respect_bass=profile != 'electric') if chord else generator.fretboard.ChordRequirements() requirements = generator.fretboard.chord_requirements(chord['_harmony'], respect_bass=profile != 'electric') if chord else generator.fretboard.ChordRequirements()
melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else () melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else ()
fresh = has_attack(notes) fresh = has_attack(notes)
candidates = chord_positions(requirements, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody) if chord else [()] candidates = chord_positions(requirements, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody, composition=composition) if chord else [()]
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))): for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
exact_positions = dict(exact) exact_positions = dict(exact)
for positions in candidates: for positions in candidates:
@@ -253,7 +257,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added)) choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added))
if not compatible or not playable_choices(choices): if not compatible or not playable_choices(choices):
continue continue
cost = voicing_cost(positions, profile, requirements.allowed, melody) if fresh else VoicingCost() cost = voicing_cost(positions, profile, requirements.allowed, melody, composition=composition) if fresh else VoicingCost()
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost) yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost)
else: else:
shape = tuple(p for _, p in exact) + positions shape = tuple(p for _, p in exact) + positions
@@ -263,7 +267,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
choices = tuple(EventPlacement(n, tuple((p, exact_positions[p['id']]) for p in n['pitches']), positions if n is chord else ()) for n in notes) choices = tuple(EventPlacement(n, tuple((p, exact_positions[p['id']]) for p in n['pitches']), positions if n is chord else ()) for n in notes)
if not playable_choices(choices): if not playable_choices(choices):
continue continue
cost = voicing_cost(shape, profile, requirements.allowed, melody) if fresh else VoicingCost() cost = voicing_cost(shape, profile, requirements.allowed, melody, composition=composition) if fresh else VoicingCost()
yield AttackCandidate(assignment, choices, shape, cost) yield AttackCandidate(assignment, choices, shape, cost)
def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]: def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
@@ -325,7 +329,8 @@ def advance_attacks(candidates, states: dict) -> dict:
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices) result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
return result return result
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False) -> dict: def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False, *, composition=None) -> dict:
composition = composition or generator.vocabulary.current()
continued = continued_sources(notes) continued = continued_sources(notes)
fresh = has_attack(notes) fresh = has_attack(notes)
held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)} held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)}
@@ -337,29 +342,30 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices) result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
if not held_ids and not continued: if not held_ids and not continued:
result = advance_attacks(attack_candidates(notes, tones, {}, {}, preserve_strings, fallback), states) result = advance_attacks(attack_candidates(notes, tones, {}, {}, preserve_strings, fallback, composition=composition), states)
else: else:
for previous in states.values(): for previous in states.values():
active = dict(previous.active) active = dict(previous.active)
held = {i: p for i, p in previous.active if i not in continued and i in held_ids} held = {i: p for i, p in previous.active if i not in continued and i in held_ids}
for candidate in attack_candidates(notes, tones, active, held, preserve_strings, fallback): for candidate in attack_candidates(notes, tones, active, held, preserve_strings, fallback, composition=composition):
distance = movement(tuple(p for _, p in previous.active), candidate.shape) if fresh else 0 distance = movement(tuple(p for _, p in previous.active), candidate.shape) if fresh else 0
keep(candidate, previous, previous.cost.moved(distance)) keep(candidate, previous, previous.cost.moved(distance))
if not result: if not result:
if not fallback and any(n.get('_harmonize') for n in notes): if not fallback and any(n.get('_harmonize') for n in notes):
return advance_positions(notes, onset, tones, states, preserve_strings, True) return advance_positions(notes, onset, tones, states, preserve_strings, True, composition=composition)
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement; single notes need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_SINGLE_FRET}; chords need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}, an anchor <= {generator.fretboard.MAX_ANCHOR_FRET}, and at most {generator.fretboard.MAX_FRET_SPAN} consecutive fret positions; held/tied strings must remain fixed') raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement; single notes need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_SINGLE_FRET}; chords need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}, an anchor <= {generator.fretboard.MAX_ANCHOR_FRET}, and at most {generator.fretboard.MAX_FRET_SPAN} consecutive fret positions; held/tied strings must remain fixed')
return result return result
def place_guitar(document: dict) -> None: def place_guitar(document: dict, *, composition=None) -> None:
"""Choose electric voicings or exact/held melody placements.""" """Choose electric voicings or exact/held melody placements."""
composition = composition or generator.vocabulary.current()
events, tones = guitar_events(document) events, tones = guitar_events(document)
states = {(): Placement(VoicingCost(), (), None, ())} states = {(): Placement(VoicingCost(), (), None, ())}
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])] groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
for index, (onset, notes) in enumerate(groups): for index, (onset, notes) in enumerate(groups):
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes) preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
states = advance_positions(notes, onset, tones, states, preserve_strings) states = advance_positions(notes, onset, tones, states, preserve_strings, composition=composition)
selected = min(states.values(), key=lambda s: (s.cost, s.active)) selected = min(states.values(), key=lambda s: (s.cost, s.active))
skipped = [] skipped = []
while selected.previous is not None: while selected.previous is not None:
-156
View File
@@ -3,8 +3,6 @@ from __future__ import annotations
import copy import copy
import fractions import fractions
import typing import typing
import generator.fretboard
import generator.pitch
MAX_INTEGER = (1 << 63) - 1 MAX_INTEGER = (1 << 63) - 1
NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th') NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th')
@@ -140,157 +138,3 @@ def make_event(identifier: str, onset: fractions.Fraction, duration: fractions.F
if 'alter' in event['pitches'][-1]: if 'alter' in event['pitches'][-1]:
event['pitches'][-1]['alter'] = rational(alteration(p['alter'])) event['pitches'][-1]['alter'] = rational(alteration(p['alter']))
return event return event
def validate_document(document: dict) -> None:
if not isinstance(document, dict) or not isinstance(document.get('metadata'), dict):
raise ValueError('score requires a metadata object')
if any(not isinstance(document['metadata'].get(name), str) for name in ('title', 'composer', 'key_signature', 'time_signature')):
raise ValueError('score metadata requires title, composer, key_signature and time_signature strings')
staves = document.get('staves', [])
if not isinstance(staves, list) or len(staves) != 1 or not isinstance(staves[0], dict):
raise ValueError('exactly one staff is currently supported')
if not isinstance(staves[0].get('measures'), list) or not staves[0]['measures']:
raise ValueError('score requires measures')
instruments = staves[0].get('instruments', [])
instrument_ids = {i['id'] for i in instruments}
if len(instruments) != len(instrument_ids) or any(not isinstance(i, str) or not i for i in instrument_ids):
raise ValueError('score instruments require unique nonempty IDs')
ids: set[str] = set()
events: dict[str, dict] = {}
tones: dict[str, tuple[dict, fractions.Fraction, fractions.Fraction, str]] = {}
ownership: dict[str, str] = {}
absolute = fractions.Fraction()
for measure in staves[0]['measures']:
if {'repeats', 'endings', 'initial_attributes'} & measure.keys():
raise ValueError('repeat notation is unsupported; regenerate scores from music written out in reading order')
length = fraction(measure['duration'])
if length <= 0:
raise ValueError('measure duration must be positive')
voices = measure.get('voices', [])
if not voices or len({v['id'] for v in voices}) != len(voices) or any(not v['id'] for v in voices):
raise ValueError('measure requires uniquely identified voice lanes')
previous_ends = {v['id']: fractions.Fraction() for v in voices}
for voice, note in ((v, n) for v in voices for n in v['notes']):
if any({'sound_ties', 'printed_ties'} & pitch.keys() for pitch in note['pitches']):
raise ValueError('pass-specific tie metadata is unsupported; regenerate scores')
if not note['id'] or note['id'] in ids:
raise ValueError(f'duplicate event ID: {note["id"]}')
ids.add(note['id'])
events[note['id']] = note
ownership[note['id']] = voice['id']
if note.get('voice', voice['id']) != voice['id']:
raise ValueError('event voice ownership disagrees with its lane')
onset, duration = fraction(note['onset']), fraction(note['duration'])
if onset < 0 or duration < 0 or onset + duration > length:
raise ValueError(f'event outside measure: {note["id"]}')
if note['kind'] not in ('note', 'rest', 'spacer'):
raise ValueError('invalid event kind')
if not note.get('grace'):
if duration == 0 or onset < previous_ends[voice['id']]:
raise ValueError('overlapping events in one rhythmic voice')
previous_ends[voice['id']] = onset + duration
elif duration != 0:
raise ValueError('grace notes must not advance metric time')
expected = metric_length(note['written'])
if not note.get('grace') and not note.get('measure_rest') and duration != expected:
raise ValueError('metric and written event durations disagree')
if note['kind'] != 'note' and note.get('pitches'):
raise ValueError('rests and spacers cannot contain pitches')
if note['kind'] == 'note' and not note.get('pitches') and note.get('notehead') != 'slash':
raise ValueError('pitched event requires at least one pitch')
for p in note.get('pitches', []):
if p.get('instrument') and p['instrument'] not in instrument_ids:
raise ValueError('note refers to an unknown score instrument')
if 'unpitched' in p:
position = p['unpitched']
if position['letter'] not in LETTERS or type(position['octave']) is not int:
raise ValueError('invalid unpitched display position')
else:
pitch_number(p)
if staves[0].get('tablature'):
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:
raise ValueError(f'{p["id"]}: invalid generated tablature position')
state = dict(measure)
for change in measure.get('attributes', []):
if fraction(change['onset']) <= onset:
state.update(change)
if dict(generator.fretboard.STANDARD_TUNING)[string] + fret != generator.pitch.sounding_pitch(p, state):
raise ValueError(f'{p["id"]}: tablature disagrees with sounding pitch')
pd = fraction(p['duration'])
if pd < 0 or (pd == 0 and not note.get('grace')) or pd > duration or not p['id'] or p['id'] in tones:
raise ValueError('invalid chord-tone duration or duplicate ID')
if not note.get('grace') and pd != metric_length(p['written']):
raise ValueError('metric and written chord-tone durations disagree')
tones[p['id']] = (p, absolute + onset, pd, voice['id'])
for harmony in measure.get('harmonies', []):
if not 0 <= fraction(harmony['onset']) < length:
raise ValueError('harmony outside measure')
for change in measure.get('attributes', []):
if not 0 <= fraction(change['onset']) < length:
raise ValueError('attribute change outside measure')
for state in [measure, *measure.get('attributes', [])]:
if 'meter' in state:
validate_meter(state['meter'])
if 'key' in state:
if len(state['key']['alterations']) != 7:
raise ValueError('key alterations must contain seven exact alterations')
for value in state['key']['alterations']:
alteration(value)
if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C', 'percussion', 'none') or not 1 <= state['clef']['line'] <= 6 or not -3 <= state['clef']['octave_change'] <= 3):
raise ValueError('invalid clef')
absolute += length
for identifier, (p, start, duration, voice) in tones.items():
if target := p.get('tie_to'):
if target not in tones:
raise ValueError(f'dangling tie: {identifier} -> {target}')
other, next_start, _, next_voice = tones[target]
same_pitch = all(p.get(k) == other.get(k) for k in ('unpitched', 'instrument')) if 'unpitched' in p else all(p.get(k) == other.get(k) for k in ('letter', 'alter', 'octave'))
if other.get('tie_from') != identifier or not same_pitch or next_voice != voice or next_start != start + duration:
raise ValueError(f'invalid tie: {identifier} -> {target}')
if staves[0].get('tablature') and p['tablature'] != other['tablature']:
raise ValueError(f'{identifier}: tied guitar tones must retain their string and fret')
if source := p.get('tie_from'):
if source not in tones or tones[source][0].get('tie_to') != identifier:
raise ValueError(f'dangling incoming tie: {identifier}')
span_ids = set()
event_order = {identifier: index for index, identifier in enumerate(events)}
for span in staves[0].get('tuplets', []):
if not span['id'] or span['id'] in span_ids or span['start'] not in ids or span['end'] not in ids or not 0 < span['note_count'] <= 1000000 or not 0 < span['space_count'] <= 1000000 or not 0 <= span['depth'] <= 16:
raise ValueError('invalid tuplet span')
members = [n['id'] for n in events.values() if span['id'] in n.get('tuplet_ids', [])]
if not members or members[0] != span['start'] or members[-1] != span['end'] or event_order[span['start']] > event_order[span['end']]:
raise ValueError('tuplet endpoints disagree with member events')
if len({ownership[n] for n in members}) != 1 or span.get('voice', ownership[members[0]]) != ownership[members[0]]:
raise ValueError('tuplet crosses independent voice lanes')
if span.get('bracket', 'auto') not in ('yes', 'no', 'auto') or span.get('show_number', 'actual') not in ('none', 'actual', 'both'):
raise ValueError('invalid tuplet appearance')
span_ids.add(span['id'])
for note in events.values():
for verse in note.get('lyrics', []):
start = tones[note['pitches'][0]['id']][1] if note['pitches'] else fractions.Fraction()
if 'extend_to' in verse and not start < fraction(verse['extend_to']) <= absolute:
raise ValueError('lyric extension endpoint is outside the score')
if any(t not in span_ids for t in note.get('tuplet_ids', [])):
raise ValueError('event refers to an unknown tuplet')
if tremolo := note.get('tremolo'):
if tremolo['type'] not in ('single', 'start', 'stop') or type(tremolo['marks']) is not int or not 0 <= tremolo['marks'] <= 8:
raise ValueError('invalid measured tremolo')
if tremolo['type'] in ('start', 'stop'):
other = events.get(tremolo.get('partner'))
expected_type = 'stop' if tremolo['type'] == 'start' else 'start'
if not other or other.get('tremolo', {}).get('partner') != note['id'] or other['tremolo']['type'] != expected_type or other['tremolo']['marks'] != tremolo['marks']:
raise ValueError('invalid paired tremolo')
for span in staves[0].get('octave_shifts', []):
if not 0 <= fraction(span['start']) < fraction(span['end']) <= absolute or not -3 <= span['octaves'] <= 3:
raise ValueError('invalid octave-shift span')
previous_end = fractions.Fraction()
for span in staves[0].get('scale_spans', []):
start, end = fraction(span['start']), fraction(span['end'])
if not previous_end <= start < end <= absolute or not isinstance(span.get('label'), str) or not span['label'].strip() or type(span.get('matched')) is not bool:
raise ValueError('invalid solo scale span')
if not isinstance(span.get('id'), str) or not span['id'] or span['id'] in ids:
raise ValueError('solo scale spans require unique IDs')
ids.add(span['id'])
previous_end = end
+2 -1
View File
@@ -8,6 +8,7 @@ import defusedxml.ElementTree
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.validation
class MusicXMLError(ValueError): class MusicXMLError(ValueError):
pass pass
@@ -621,7 +622,7 @@ class Importer:
metadata.update({'key_signature': measures[0]['key_signature'], 'time_signature': measures[0]['time_signature'], 'key': copy.deepcopy(measures[0]['key'])}) metadata.update({'key_signature': measures[0]['key_signature'], 'time_signature': measures[0]['time_signature'], 'key': copy.deepcopy(measures[0]['key'])})
result = {'metadata': metadata, 'staves': [{'id': 'staff_source', 'name': definitions[0].findtext('part-name', 'Source'), 'role': 'vocal', 'clef': copy.deepcopy(measures[0]['clef']), 'tuplets': self.tuplets, 'octave_shifts': self.octaves, 'measures': measures}]} result = {'metadata': metadata, 'staves': [{'id': 'staff_source', 'name': definitions[0].findtext('part-name', 'Source'), 'role': 'vocal', 'clef': copy.deepcopy(measures[0]['clef']), 'tuplets': self.tuplets, 'octave_shifts': self.octaves, 'measures': measures}]}
result['staves'][0]['instruments'] = instruments result['staves'][0]['instruments'] = instruments
generator.model.validate_document(result) generator.validation.validate_document(result)
return result return result
def load_musicxml(path: pathlib.Path) -> dict: def load_musicxml(path: pathlib.Path) -> dict:
+2 -1
View File
@@ -7,6 +7,7 @@ import xml.etree.ElementTree
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.validation
def element(parent, tag, content=None, **attributes): def element(parent, tag, content=None, **attributes):
node = xml.etree.ElementTree.SubElement(parent, tag, {k.replace('_', '-'): str(v) for k, v in attributes.items() if v is not None}) node = xml.etree.ElementTree.SubElement(parent, tag, {k.replace('_', '-'): str(v) for k, v in attributes.items() if v is not None})
@@ -209,7 +210,7 @@ def write_barline(bar, measure, location):
element(node, 'bar-style', measure.get('barlines', {}).get(location, 'regular')) element(node, 'bar-style', measure.get('barlines', {}).get(location, 'regular'))
def musicxml_tree(document: dict, *, tablature=False) -> xml.etree.ElementTree.ElementTree: def musicxml_tree(document: dict, *, tablature=False) -> xml.etree.ElementTree.ElementTree:
generator.model.validate_document(document) generator.validation.validate_document(document)
root = xml.etree.ElementTree.Element('score-partwise', {'version': '4.0'}) root = xml.etree.ElementTree.Element('score-partwise', {'version': '4.0'})
metadata, staff = document['metadata'], document['staves'][0] metadata, staff = document['metadata'], document['staves'][0]
element(element(root, 'work'), 'work-title', metadata['title']) element(element(root, 'work'), 'work-title', metadata['title'])
-1
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import fractions import fractions
import pathlib import pathlib
import generator.accompaniment
import generator.fretboard import generator.fretboard
import generator.model import generator.model
import generator.pitch import generator.pitch
+1 -61
View File
@@ -1,14 +1,8 @@
"""Page timing and supplied recordings. No note synthesis or audio transformation.""" """Pure page coverage and performance timing."""
from __future__ import annotations from __future__ import annotations
import fractions import fractions
import hashlib
import json
import math import math
import pathlib
import re
import shutil
import wave
import generator.model import generator.model
@@ -117,57 +111,3 @@ def timing(document):
return dict(song_id=document['metadata'].get('song_id', ''), return dict(song_id=document['metadata'].get('song_id', ''),
duration=generator.model.rational(times[-1]), duration=generator.model.rational(times[-1]),
sustain_start=generator.model.rational(sustain_start), count_in_beats=4, clicks=clicks) sustain_start=generator.model.rational(sustain_start), 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")
+5 -17
View File
@@ -7,7 +7,6 @@ import json
import math import math
import pathlib import pathlib
import shutil import shutil
import tempfile
import time import time
import PIL.PdfParser import PIL.PdfParser
@@ -19,7 +18,7 @@ import generator.engraving.layout
import generator.engraving.notation import generator.engraving.notation
import generator.engraving.raster import generator.engraving.raster
import generator.engraving.types import generator.engraving.types
import generator.model import generator.validation
# Two layout units per PDF point: guitar staff spacing is five points. # Two layout units per PDF point: guitar staff spacing is five points.
# Raster resolution is independent of layout and the device's display theme. # Raster resolution is independent of layout and the device's display theme.
@@ -30,7 +29,7 @@ MEDIA_BOX = [0, 0, 612, 792]
VERSION = 'letter-parts-v1' VERSION = 'letter-parts-v1'
def layout_part(document, fonts): def layout_part(document, fonts):
generator.model.validate_document(document) generator.validation.validate_document(document)
style = dataclasses.replace(generator.engraving.types.score_style(document, STYLE), system_gap=STYLE.system_gap) style = dataclasses.replace(generator.engraving.types.score_style(document, STYLE), system_gap=STYLE.system_gap)
# Measure numbers are centered over barlines and can extend left of the # Measure numbers are centered over barlines and can extend left of the
# staff. Reserve their actual overhang inside the half-inch paper margin. # staff. Reserve their actual overhang inside the half-inch paper margin.
@@ -123,7 +122,7 @@ def renderer_key(fonts):
def restore(entry, key, target): def restore(entry, key, target):
try: try:
if generator.engraving.cache.has_symlink(entry): if generator.cache.has_symlink(entry):
return None return None
pdf, manifest = entry / 'part.pdf', entry / 'print.json' pdf, manifest = entry / 'part.pdf', entry / 'print.json'
if pdf.is_symlink() or manifest.is_symlink(): if pdf.is_symlink() or manifest.is_symlink():
@@ -138,21 +137,10 @@ def restore(entry, key, target):
return None return None
def store(entry, key, target, pages): def store(entry, key, target, pages):
if generator.engraving.cache.has_symlink(entry.parent): with generator.cache.stage_entry(entry, '.print_') as staged:
raise ValueError('print cache must not contain symlinks')
entry.parent.mkdir(parents=True, exist_ok=True)
if entry.is_symlink() or entry.is_file():
entry.unlink()
entry.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.print_', dir=entry.parent) as temporary:
staged = pathlib.Path(temporary)
shutil.copyfile(target, staged / 'part.pdf') shutil.copyfile(target, staged / 'part.pdf')
(staged / 'print.json').write_text(json.dumps({'key': key, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest(), 'pages': pages})) (staged / 'print.json').write_text(json.dumps({'key': key, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest(), 'pages': pages}))
for name in ('part.pdf', 'print.json'): generator.cache.publish_files(entry, staged, ('part.pdf', 'print.json'))
destination = entry / name
if destination.is_dir() and not destination.is_symlink():
shutil.rmtree(destination)
(staged / name).replace(destination)
def generate_tree(root, font_path, *, cache_dir=None, progress=False): def generate_tree(root, font_path, *, cache_dir=None, progress=False):
"""Called inside the normal build's atomic staging directory.""" """Called inside the normal build's atomic staging directory."""
+13 -332
View File
@@ -1,342 +1,23 @@
"""Independent, reproducible role generation and non-composing assembly. """File discovery, process scheduling and publication of independent roles."""
Only source documents and production resources enter generate_role. Completed
roles and published assets are downstream values, never composition inputs.
"""
from __future__ import annotations from __future__ import annotations
import concurrent.futures import concurrent.futures
import copy import copy
import dataclasses
import hashlib
import functools
import json import json
import multiprocessing import multiprocessing
import os
import pathlib import pathlib
import tempfile
import time
import fractions
import generator.accompaniment import generator.arrange.accompaniment
import generator.comping import generator.arrange.assembly
import generator.fretboard import generator.composition
import generator.model import generator.composition_types
import generator.musicxml import generator.musicxml
import generator.parts import generator.parts
import generator.score import generator.score
import generator.vocabulary import generator.score_io
import generator.arrange.accompaniment import generator.source
import generator.arrange.ending
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 = generator.score.source_staff(prepared)
for i, m in enumerate(staff['measures']):
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 = 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 accompaniment_key(self):
document = self.document()
document['metadata'].pop('tempo', None)
for measure in generator.score.source_staff(document)['measures']:
measure['tempos'] = []
return SourceAnalysis.read(document).musical_key
def windows(document, part):
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((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 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 - 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 - generator.model.fraction(source[0]['duration'])
a, _, old = result[-1]
result[-1] = (a, start, old)
result.append((start, end, part + '_' + role))
cursor = end
result.append((cursor, cursor + generator.arrange.ending.duration(source), part + ('_accompaniment' if part != 'vocal' else '_rest')))
return tuple(result)
def dependencies(role, resources, measures=None):
names = ['harmony', 'lead_ins'] if role.endswith('_solo') else []
accompaniment = {}
if role.endswith('_accompaniment'):
policy = generator.vocabulary.accompaniment(role.split('_')[0], resources)
accompaniment = {'accompaniment': generator.comping_rhythm.relevant_policy(policy, measures) if measures is not None else policy}
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
**({'voicing_families': generator.vocabulary.accompaniment('electric', resources)['voicing_families']} if role == 'electric_melody' else {}),
**accompaniment,
**{name: resources[name] for name in names}}
def _code_key(role):
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'))
if role.endswith('_solo'):
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', 'comping_rhythm.py', 'chord_families.py', 'accompaniment.py', 'guitar.py')]
if role == 'acoustic_accompaniment':
paths += [root / 'acoustic_voicing.py', root / 'acoustic.py']
elif role == 'electric_melody':
paths += [root / 'guitar.py', root / 'melody.py', root / 'chord_families.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 {}
@functools.lru_cache(maxsize=4)
def _solo_model(directory, fingerprint):
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 = fractions.Fraction()
for measure in document['staves'][0]['measures']:
length = generator.model.fraction(measure['duration'])
for voice in measure['voices']:
selected = []
for n in voice['notes']:
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'] = 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'] = generator.arrange.pickup.retain_links(staff['measures'], staff.get('tuplets', []))
def _fill(notes, length, prefix, voice='voice_arranged'):
notes = sorted(notes, key=lambda n: generator.model.fraction(n['onset']))
result = []
cursor = fractions.Fraction()
def rests(end):
nonlocal cursor
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 = 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):
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
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 = 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, generator.score.source_staff(source.document())['measures']), '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 = 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'] == 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}
choice_source = source.accompaniment_key() if role.endswith('_accompaniment') else source.musical_key
choice_data, choice_settings = inputs['vocabulary'], settings
if role.endswith('_accompaniment'):
# Voicing weights invalidate the concrete cache, but must not reshuffle
# rhythm patterns. Removing this new field preserves the pre-policy seed.
choice_data = copy.deepcopy(choice_data)
choice_data['roles']['role'].pop('voicing', None)
for field in ('provenance', 'license', 'research'):
choice_data['accompaniment'].pop(field, None)
choice_settings = {k: v for k, v in settings.items() if k != 'voicing'}
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': choice_settings, 'data': choice_data, 'version': algorithm})
details = {}
solo_report = ''
composition_timings = {}
with generator.vocabulary.using(resources, role, settings | {'choice_seed': seed}):
if role.endswith('_solo'):
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 = 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 = generator.accompaniment.carried_chords(result['staves'][0]['measures'])[first - 1]
if registered:
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 = 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 = generator.comping.Canonical(document, part, seed)
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'accompaniment'}, accompaniment=canonical)
details = {'canonical_comping': canonical.report, 'accompaniment_rhythm': canonical.rhythm_report, 'groove_cycles': canonical.groove_report, 'rhythm_source': choice_source}
else:
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)
if role == "acoustic_accompaniment":
generator.acoustic.validate_comping(result)
staff = result['staves'][0]
for measure in staff['measures']:
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))
if 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 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']):
# Tuplet rests belong to the source/role rhythm and must survive union.
lanes = {}
for d in selected:
for voice in d['staves'][0]['measures'][i]['voices']:
lane = voice['id'] if part == 'acoustic' and voice['id'] in ('upper', 'bass') else 'voice_arranged'
sounding = [copy.deepcopy(n) for n in voice['notes'] if n['pitches'] or n.get('tuplet_ids')]
if sounding:
lanes.setdefault(lane, []).extend(sounding)
lanes = lanes or {'voice_arranged': []}
m['voices'] = [{'id': lane, 'notes': _fill(notes, generator.model.fraction(m['duration']), m['id'] + '_' + lane, lane)} for lane, notes in lanes.items()]
# 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'] = generator.arrange.pickup.retain_links(staff['measures'], staff['tuplets'])
owners = {n['id']: v['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
for span in staff['tuplets']:
span['voice'] = owners[span['start']]
generator.model.validate_document(document)
generator.fretboard.validate_document(document)
documents[part] = document
return documents
def _job(request): def _job(request):
source, role, directory, settings, cache = request return generator.composition.generate_role(request.source, request.role, request.directory, json.loads(request.settings_json), request.cache)
return generate_role(source, role, directory, settings, cache)
def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=False, timings=None): 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:
@@ -345,8 +26,8 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
sources = {} sources = {}
for path in generator.parts.source_paths(source_dir): for path in generator.parts.source_paths(source_dir):
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path)) document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
sources[path] = (document, SourceAnalysis.read(document)) sources[path] = (document, generator.source.SourceAnalysis.read(document))
requests = [(path, role, (s, role, source_dir.parent, {}, cache_dir)) for path, (_, s) in sources.items() for role in ROLES] requests = [(path, role, generator.composition_types.RoleRequest(s, role, source_dir.parent, cache=cache_dir)) for path, (_, s) in sources.items() for role in generator.composition_types.ROLES]
results = {p: {} for p in sources} results = {p: {} for p in sources}
def accept(path, role, value): def accept(path, role, value):
@@ -372,16 +53,16 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
except Exception as error: except Exception as error:
raise ValueError(f'{path.name}/{role}: {error}') from error raise ValueError(f'{path.name}/{role}: {error}') from error
for path, (original, source) in sources.items(): for path, (original, source) in sources.items():
assembled = assemble(source, results[path]) assembled = generator.arrange.assembly.assemble(source, results[path])
generator.arrange.accompaniment.audit(original, assembled) generator.arrange.accompaniment.audit(original, assembled)
reuse = generator.arrange.accompaniment.audit(original, {role: value['document'] for role, value in results[path].items()}, role_documents=True) reuse = generator.arrange.accompaniment.audit(original, {role: value['document'] for role, value in results[path].items()}, role_documents=True)
for part, document in assembled.items(): for part, document in assembled.items():
document['metadata'] = copy.deepcopy(original['metadata']) document['metadata'] = copy.deepcopy(original['metadata'])
document['metadata']['song_id'] = generator.score.slugify(path.stem) document['metadata']['song_id'] = generator.score.slugify(path.stem)
generator.score.write_json(output_dir / (generator.score.slugify(path.stem) + '_' + part + '.json'), document) generator.score_io.write_json(output_dir / (generator.score.slugify(path.stem) + '_' + part + '.json'), document)
analysis_dir = output_dir.parent / 'analysis' analysis_dir = output_dir.parent / 'analysis'
analysis_dir.mkdir(parents=True, exist_ok=True) 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)) (analysis_dir / (generator.score.slugify(path.stem) + '.txt')).write_text('\n'.join(results[path][r]['report'] for r in generator.composition_types.ROLES))
# Not a score file: diagnostics are deliberately outside the songs schema. # 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()} 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()}
for role, audit in reuse.items(): for role, audit in reuse.items():
+29
View File
@@ -0,0 +1,29 @@
"""Role cache persistence; cache identity never supplies musical randomness."""
import json
import generator.cache
import generator.fretboard
import generator.validation
ENTRYPOINTS = ('generator.composition',)
def code_key():
return generator.cache.fingerprint(generator.cache.source_identity(ENTRYPOINTS))
def restore(path, key):
if path is None or not path.is_file() or path.is_symlink():
return None
try:
entry = json.loads(path.read_text())
value = entry['result']
if entry['key'] == key and entry['digest'] == generator.cache.fingerprint(value):
generator.validation.validate_document(value['document'])
generator.fretboard.validate_document(value['document'])
return {**value, 'cache_hit': True}
except (OSError, ValueError, KeyError, TypeError):
pass
return None
def store(path, key, value):
if path is not None:
generator.cache.write_json(path, {'key': key, 'digest': generator.cache.fingerprint(value), 'result': value})
+1 -58
View File
@@ -1,53 +1,8 @@
"""Pure score navigation, cloning and identity operations."""
from __future__ import annotations from __future__ import annotations
import copy import copy
import json
import pathlib
import re import re
import generator.engraving.validation
import generator.fretboard
import generator.model
import generator.pico_artwork
import generator.performance
def load_json(path: pathlib.Path) -> dict:
document = json.loads(path.read_text(encoding='utf-8'))
return validate_json(document, path)
def validate_json(document: dict, path: pathlib.Path, validated_pages: set[str] | None = None) -> dict:
if not isinstance(document, dict):
raise ValueError('expected a score object')
if 'music' in document:
presentation = document.get('presentation', {})
if not isinstance(document['music'], dict) or not isinstance(presentation, dict) or not isinstance(presentation.get('pages'), list) or not presentation['pages']:
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_views(document['music'], presentation['pages'], presentation.get('playback_pages'))
if document.get('performance') != generator.performance.timing(document['music']):
raise ValueError('invalid performance timing')
for page in presentation['pages'] + presentation['playback_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')
name = page['path']
relative = pathlib.Path(name)
if '\x00' in name or '\\' in name or relative.is_absolute() or any(part in ('', '.', '..') for part in name.split('/')) or relative.suffix != '.png':
raise ValueError('invalid score page path')
resolved = (path.parent / relative).resolve()
if not resolved.is_relative_to(path.parent.resolve()):
raise ValueError('score page escapes its asset directory')
generator.engraving.validation.check_page(resolved, validated_pages, expected_sha256=page['sha256'])
document = document['music']
generator.model.validate_document(document)
return document
def write_json(path: pathlib.Path, document: dict) -> None:
generator.model.validate_document(document)
generator.fretboard.validate_document(document)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def slugify(value: str) -> str: def slugify(value: str) -> str:
return re.sub(r'[^a-z0-9]+', '_', value.strip().lower()).strip('_') or 'score' return re.sub(r'[^a-z0-9]+', '_', value.strip().lower()).strip('_') or 'score'
@@ -124,15 +79,3 @@ def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
def make_arranged_document(source_document: dict, staff: dict, *, staff_id: str, staff_name: str, role: str, arrangement_part_type: str, measures: list[dict], tuplets=None, octave_shifts=None) -> dict: def make_arranged_document(source_document: dict, staff: dict, *, staff_id: str, staff_name: str, role: str, arrangement_part_type: str, measures: list[dict], tuplets=None, octave_shifts=None) -> dict:
return {'metadata': copy.deepcopy(source_document['metadata']), 'staves': [{'id': staff_id, 'name': staff_name, 'clef': copy.deepcopy(staff['clef']), 'role': role, 'arranged': True, 'arrangement_part_type': arrangement_part_type, 'measures': measures, 'tuplets': tuplets or [], 'octave_shifts': copy.deepcopy(octave_shifts or []), 'instruments': copy.deepcopy(staff.get('instruments', []))}]} return {'metadata': copy.deepcopy(source_document['metadata']), 'staves': [{'id': staff_id, 'name': staff_name, 'clef': copy.deepcopy(staff['clef']), 'role': role, 'arranged': True, 'arrangement_part_type': arrangement_part_type, 'measures': measures, 'tuplets': tuplets or [], 'octave_shifts': copy.deepcopy(octave_shifts or []), 'instruments': copy.deepcopy(staff.get('instruments', []))}]}
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:
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'))
music = validate_json(document, path, validated_pages)
generator.fretboard.validate_document(music)
if 'presentation' in document:
generator.pico_artwork.validate_title(root, document['presentation'].get('title_image'))
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import json
import pathlib
import generator.engraving.validation
import generator.fretboard
import generator.performance
import generator.pico_artwork
import generator.validation
def load_json(path: pathlib.Path) -> dict:
document = json.loads(path.read_text(encoding='utf-8'))
return validate_json(document, path)
def validate_json(document: dict, path: pathlib.Path, validated_pages: set[str] | None = None) -> dict:
if not isinstance(document, dict):
raise ValueError('expected a score object')
if 'music' in document:
presentation = document.get('presentation', {})
if not isinstance(document['music'], dict) or not isinstance(presentation, dict) or not isinstance(presentation.get('pages'), list) or not presentation['pages']:
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_views(document['music'], presentation['pages'], presentation.get('playback_pages'))
if document.get('performance') != generator.performance.timing(document['music']):
raise ValueError('invalid performance timing')
for page in presentation['pages'] + presentation['playback_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')
name = page['path']
relative = pathlib.Path(name)
if '\x00' in name or '\\' in name or relative.is_absolute() or any(part in ('', '.', '..') for part in name.split('/')) or relative.suffix != '.png':
raise ValueError('invalid score page path')
resolved = (path.parent / relative).resolve()
if not resolved.is_relative_to(path.parent.resolve()):
raise ValueError('score page escapes its asset directory')
generator.engraving.validation.check_page(resolved, validated_pages, expected_sha256=page['sha256'])
document = document['music']
generator.validation.validate_document(document)
return document
def write_json(path: pathlib.Path, document: dict) -> None:
generator.validation.validate_document(document)
generator.fretboard.validate_document(document)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
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:
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'))
music = validate_json(document, path, validated_pages)
generator.fretboard.validate_document(music)
if 'presentation' in document:
generator.pico_artwork.validate_title(root, document['presentation'].get('title_image'))
+15 -12
View File
@@ -6,20 +6,21 @@ policy. Unknown song/role pairs go to the learned composer.
""" """
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import fractions
import hashlib import hashlib
import json import json
import pathlib import pathlib
import fractions
import generator.harmonic_time
import generator.musicxml import generator.musicxml
import generator.parts import generator.parts
import generator.training_io import generator.solo.harmonic
import generator.harmonic_time
import generator.solo.analysis
import generator.solo.types
import generator.solo.placement import generator.solo.placement
import generator.solo.profiles import generator.solo.profiles
import generator.solo.harmonic import generator.solo.types
import generator.production import generator.source
import generator.training_io
import generator.vocabulary
def sounding(evidence, offset=fractions.Fraction()): def sounding(evidence, offset=fractions.Fraction()):
result = [] result = []
@@ -32,9 +33,10 @@ def sounding(evidence, offset=fractions.Fraction()):
result.append(generator.solo.types.Note(fractions.Fraction(event['start']) - offset, fractions.Fraction(event['end']) - offset, pitch, role='authored', event_id=f'authored_{i}')) 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) return tuple(result)
def validate_evidence(document, evidence): def validate_evidence(document, evidence, *, composition=None):
"""Training uses the same source-form harmonic context as solo playback.""" """Training uses the same source-form harmonic context as solo playback."""
h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(document['staves'][0])) composition = composition or generator.vocabulary.current()
h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(document['staves'][0]), composition=composition)
checked = h.evaluate(sounding(evidence), complete=True) checked = h.evaluate(sounding(evidence), complete=True)
if not checked.valid: if not checked.valid:
raise ValueError('Unsupported harmonic relationship in authored solo: ' + checked.error) raise ValueError('Unsupported harmonic relationship in authored solo: ' + checked.error)
@@ -56,7 +58,7 @@ def lookup(source, role, data_dir):
if not path.is_relative_to((root / 'songs').resolve()): if not path.is_relative_to((root / 'songs').resolve()):
raise ValueError('Registered sources must be in data/songs') raise ValueError('Registered sources must be in data/songs')
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(path)) doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
if generator.production.SourceAnalysis.read(doc).musical_key == source.musical_key: if generator.source.SourceAnalysis.read(doc).musical_key == source.musical_key:
target = (manifest.parent / entry['solo']).resolve() target = (manifest.parent / entry['solo']).resolve()
if not target.is_relative_to(manifest.parent.resolve()): if not target.is_relative_to(manifest.parent.resolve()):
raise ValueError('Registered solo must be inside data/training') raise ValueError('Registered solo must be inside data/training')
@@ -67,7 +69,8 @@ def lookup(source, role, data_dir):
raise ValueError('Multiple authored solos registered for the same source music and role') raise ValueError('Multiple authored solos registered for the same source music and role')
return matches[0] if matches else None return matches[0] if matches else None
def realize(entry, source_analysis, measures, part, start, end, initial=None): def realize(entry, source_analysis, measures, part, start, end, initial=None, *, composition=None):
composition = composition or generator.vocabulary.current()
evidence = generator.training_io.read(entry['path'], part, pitch_convention=entry['pitch_convention']) evidence = generator.training_io.read(entry['path'], part, pitch_convention=entry['pitch_convention'])
if evidence['discrepancies']: if evidence['discrepancies']:
raise ValueError(entry['id'] + ': invalid authored timing/ties: ' + str(evidence['discrepancies'])) raise ValueError(entry['id'] + ': invalid authored timing/ties: ' + str(evidence['discrepancies']))
@@ -86,7 +89,7 @@ def realize(entry, source_analysis, measures, part, start, end, initial=None):
if placed is None: if placed is None:
raise ValueError(entry['id'] + ': no legal complete guitar placement') 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)) 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) checked = generator.solo.harmonic.Harmony(spans, composition=composition).evaluate(placed[1], complete=True)
if not checked.valid: if not checked.valid:
raise ValueError(entry['id'] + ': ' + checked.error) 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) phrase = generator.solo.types.Phrase('authored', start, end, placed[1], 'authored', 'authored', 'handoff', theme=entry['id'], expressions=checked.contracts)
+17 -14
View File
@@ -1,26 +1,28 @@
"""The sole production solo composer: frozen learned model plus music validity.""" """The sole production solo composer: frozen learned model plus music validity."""
import dataclasses import dataclasses
import time
import fractions import fractions
import pathlib import pathlib
import time
import generator.harmonic_time 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.decoder
import generator.solo.groups import generator.solo.groups
import generator.solo.harmonic
import generator.solo.learned
import generator.solo.placement
import generator.solo.profiles
import generator.solo.types
import generator.vocabulary
def compose(analysis, measures, part, end, *, learned_model=None, initial=None, expressions_enabled=True, start=fractions.Fraction()): def compose(analysis, measures, part, end, *, learned_model=None, initial=None, expressions_enabled=True, start=fractions.Fraction(), composition=None):
composition = composition or generator.vocabulary.current()
began = time.monotonic() 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)) 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) profile = generator.solo.profiles.for_part(part, analysis)
harmony = generator.solo.harmonic.Harmony(spans, profile) harmony = generator.solo.harmonic.Harmony(spans, profile, composition=composition)
model = learned_model or generator.solo.learned.Model.load(pathlib.Path(__file__).resolve().parents[2] / 'data/models/solo.json') 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') settings = composition.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', '')) 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', ''), composition=composition)
selected = builder.compose() selected = builder.compose()
decoded = time.monotonic() decoded = time.monotonic()
placed = generator.solo.placement.place(selected.notes, analysis.tempo, profile=profile) placed = generator.solo.placement.place(selected.notes, analysis.tempo, profile=profile)
@@ -48,10 +50,11 @@ def compose(analysis, measures, part, end, *, learned_model=None, initial=None,
'counts': dict(builder.counts), 'frontiers': builder.trace, 'completed_candidates': [ '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)], {'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) 'expressions_enabled': expressions_enabled}, profile, start)
validate(result) validate(result, composition=composition)
return result return result
def validate(solo): def validate(solo, *, composition=None):
composition = composition or generator.vocabulary.current()
previous = None previous = None
for n in solo.notes(): 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: if n.start < solo.start or n.end > solo.end or n.end <= n.start or previous and previous.end > n.start:
@@ -59,7 +62,7 @@ def validate(solo):
if n.position not in generator.solo.placement.positions(n.pitch.number(), solo.profile): if n.position not in generator.solo.placement.positions(n.pitch.number(), solo.profile):
raise ValueError('invalid guitar placement') raise ValueError('invalid guitar placement')
previous = n previous = n
checked = generator.solo.harmonic.Harmony(solo.harmony).evaluate(solo.notes(), until=solo.end, complete=True, checked = generator.solo.harmonic.Harmony(solo.harmony, composition=composition).evaluate(solo.notes(), until=solo.end, complete=True,
expressions=tuple(c for p in solo.phrases for c in p.expressions)) expressions=tuple(c for p in solo.phrases for c in p.expressions))
if not checked.valid: if not checked.valid:
raise ValueError(checked.error) raise ValueError(checked.error)
+19 -17
View File
@@ -8,17 +8,18 @@ legacy fallback. A bounded restart handles an exhausted/entirely silent sample.
from __future__ import annotations from __future__ import annotations
import collections import collections
import dataclasses import dataclasses
import hashlib
import random
import math
import fractions import fractions
import hashlib
import math
import random
import generator.harmony
import generator.model import generator.model
import generator.vocabulary
import generator.solo.expressions import generator.solo.expressions
import generator.solo.groups import generator.solo.groups
import generator.solo.learned import generator.solo.learned
import generator.solo.types import generator.solo.types
import generator.harmony import generator.vocabulary
class SearchExhausted(ValueError): class SearchExhausted(ValueError):
pass pass
@@ -35,7 +36,8 @@ class State:
def event_key(events): return tuple((a, b, -1 if p is None else p) for a, b, p in events) def event_key(events): return tuple((a, b, -1 if p is None else p) for a, b, p in events)
class Decoder: class Decoder:
def __init__(self, analysis, harmony, model, role, start, end, *, expressions_enabled=True, attempts=4, seed=''): def __init__(self, analysis, harmony, model, role, start, end, *, expressions_enabled=True, attempts=4, seed='', composition=None):
self.composition = composition or generator.vocabulary.current()
if type(attempts) is not int or not 1 <= attempts <= 16: if type(attempts) is not int or not 1 <= attempts <= 16:
raise ValueError('attempts must be between 1 and 16') raise ValueError('attempts must be between 1 and 16')
self.analysis, self.harmony, self.model, self.role = analysis, harmony, model, role self.analysis, self.harmony, self.model, self.role = analysis, harmony, model, role
@@ -45,7 +47,7 @@ class Decoder:
self.seed = seed or hashlib.sha256(repr((source, role, generator.solo.learned.VERSION)).encode()).hexdigest() self.seed = seed or hashlib.sha256(repr((source, role, generator.solo.learned.VERSION)).encode()).hexdigest()
self.expressions_enabled = expressions_enabled self.expressions_enabled = expressions_enabled
self.low, self.high = harmony.profile.register self.low, self.high = harmony.profile.register
limits = generator.vocabulary.data('roles')['difficulty'] limits = self.composition.data('roles')['difficulty']
self.durations = tuple(d for d in model.durations if 0 < d <= 4 and d.denominator in (1, 2, 4) and 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'])) (d.denominator != 4 or analysis.tempo <= limits['sixteenth_max_bpm']))
self.allow_triplets = analysis.tempo <= limits['triplet_max_bpm'] self.allow_triplets = analysis.tempo <= limits['triplet_max_bpm']
@@ -92,12 +94,12 @@ class Decoder:
bases = [] bases = []
regions = [(None, n)] regions = [(None, n)]
regions.extend((s.start, dataclasses.replace(n, start=s.start)) for s in self.harmony.spans 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'])) if n.start < s.start < n.end and s.start - n.start <= fractions.Fraction(self.composition.data('harmony')['anticipation_max']))
for boundary, arrived in regions: 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] 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: if not exposed:
continue continue
for rule in generator.vocabulary.data('harmony')['colors']: for rule in self.composition.data('harmony')['colors']:
if arrived.end - arrived.start > fractions.Fraction(rule['max_beats']): if arrived.end - arrived.start > fractions.Fraction(rule['max_beats']):
continue continue
if any(s.chord == 'N.C.' or not generator.solo.expressions.eligible_color(rule, s, self.harmony) or if any(s.chord == 'N.C.' or not generator.solo.expressions.eligible_color(rule, s, self.harmony) or
@@ -139,7 +141,7 @@ class Decoder:
if self.harmony.evaluate((n,), complete=True, expressions=(c,), infer=False).valid: if self.harmony.evaluate((n,), complete=True, expressions=(c,), infer=False).valid:
return tuple([c]) return tuple([c])
for arriving in self.harmony.spans: 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']): if n.start < arriving.start < n.end and arriving.start - n.start <= fractions.Fraction(self.composition.data('harmony')['anticipation_max']):
c = generator.solo.types.ExpressionContract(n.event_id + ':anticipation', 'anticipation', 'early_chord_tone', (n,), n, arriving.chord, arriving.start) 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: if self.harmony.evaluate((n,), complete=True, expressions=(c,), infer=False).valid:
return tuple([c]) return tuple([c])
@@ -175,9 +177,9 @@ class Decoder:
for rule, boundary in self.color_bases(n): for rule, boundary in self.color_bases(n):
if rule.get('resolve'): if rule.get('resolve'):
for gap in (fractions.Fraction(), fractions.Fraction(1, 2), fractions.Fraction(1)): for gap in (fractions.Fraction(), fractions.Fraction(1, 2), fractions.Fraction(1)):
if gap <= fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap']): if gap <= fractions.Fraction(self.composition.data('harmony')['resolution_gap']):
requests.append(('color', rule, boundary, n.end + gap, (-2, -1, 1, 2))) requests.append(('color', rule, boundary, n.end + gap, (-2, -1, 1, 2)))
delay = generator.vocabulary.data('lead_ins')['delayed_approach'] delay = self.composition.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): 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']): for arrival in map(fractions.Fraction, delay['arrivals']):
if n.end <= n.start + arrival <= n.start + fractions.Fraction(delay['deadline']): if n.end <= n.start + arrival <= n.start + fractions.Fraction(delay['deadline']):
@@ -219,7 +221,7 @@ class Decoder:
if self.expressions_enabled and not self.harmony.ordinary(n): if self.expressions_enabled and not self.harmony.ordinary(n):
for events, ns, contracts in self.resolutions(n): for events, ns, contracts in self.resolutions(n):
add(events, ns, contracts) add(events, ns, contracts)
rule = generator.vocabulary.data('lead_ins')['passing'] rule = self.composition.data('lead_ins')['passing']
if duration <= fractions.Fraction(rule['max_note']): if duration <= fractions.Fraction(rule['max_note']):
for step in rule['steps']: for step in rule['steps']:
p = number + step p = number + step
@@ -235,8 +237,8 @@ class Decoder:
c = generator.solo.types.ExpressionContract(n.event_id + ':passing', 'passing', rule['id'], (n,), target, self.harmony.at(end).chord) 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)) add(((time, end, number), (end, end + hold, p)), (n, target), (c, *extra))
if self.expressions_enabled: if self.expressions_enabled:
limits = generator.vocabulary.data('roles')['difficulty'] limits = self.composition.data('roles')['difficulty']
for rule in generator.vocabulary.data('lead_ins')['patterns']: for rule in self.composition.data('lead_ins')['patterns']:
difficulty = rule.get('difficulty') difficulty = rule.get('difficulty')
if difficulty and self.analysis.tempo > limits[difficulty + '_max_bpm']: if difficulty and self.analysis.tempo > limits[difficulty + '_max_bpm']:
continue continue
@@ -313,9 +315,9 @@ class Decoder:
return None 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) 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]): if self.harmony.ordinary(state.notes[-1]):
_, error = generator.solo.expressions.validate((state.notes[-1], *notes), contracts, self.harmony) _, error = generator.solo.expressions.validate((state.notes[-1], *notes), contracts, self.harmony, composition=self.composition)
else: else:
_, error = generator.solo.expressions.validate((*state.notes, *notes), (*state.contracts, *contracts), self.harmony) _, error = generator.solo.expressions.validate((*state.notes, *notes), (*state.contracts, *contracts), self.harmony, composition=self.composition)
if error: if error:
return None return None
history = state.history history = state.history
+21 -18
View File
@@ -1,13 +1,14 @@
"""Typed, target-driven harmonic expression against written harmony and explicit musical relationships.""" """Typed, target-driven harmonic expression against written harmony and explicit musical relationships."""
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import functools
import fractions import fractions
import functools
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.pitch import generator.pitch
import generator.vocabulary
import generator.solo.types import generator.solo.types
import generator.vocabulary
@functools.lru_cache(maxsize=512) @functools.lru_cache(maxsize=512)
def parsed_chord(symbol): def parsed_chord(symbol):
@@ -90,8 +91,9 @@ def _eligible_color(rule, span, harmony):
return False return False
return True return True
def validate(notes, contracts, harmony): def validate(notes, contracts, harmony, *, composition=None):
"""Return event permissions only after checking complete actual gestures.""" """Return event permissions only after checking complete actual gestures."""
composition = composition or generator.vocabulary.current()
actual = {n.event_id: n for n in notes} actual = {n.event_id: n for n in notes}
approved = {} approved = {}
@@ -115,7 +117,7 @@ def validate(notes, contracts, harmony):
if not previous or previous.chord.strip('()') == span.chord.strip('()'): if not previous or previous.chord.strip('()') == span.chord.strip('()'):
return {}, c.id + ': anticipation requires a harmonic change' return {}, c.id + ': anticipation requires a harmonic change'
if c.kind in ('approach', 'enclosure', 'connection'): 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) rule = next((r for r in composition.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']): if not rule or len(c.events) != len(rule['semitones']):
return {}, c.id + ': unknown approach formula' return {}, c.id + ': unknown approach formula'
arrival = c.boundary if c.boundary is not None else target.start arrival = c.boundary if c.boundary is not None else target.start
@@ -129,7 +131,7 @@ def validate(notes, contracts, harmony):
return {}, c.id + ': invalid internal approach gaps' return {}, c.id + ': invalid internal approach gaps'
gap = fractions.Fraction(rule['gap']) gap = fractions.Fraction(rule['gap'])
cursor = target.start - gap - sum((fractions.Fraction(d) for d in rule['durations']), fractions.Fraction()) - sum(gaps, fractions.Fraction()) 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']): if target.start - cursor > fractions.Fraction(composition.data('lead_ins')['max_approach']):
return {}, c.id + ': approach too long' return {}, c.id + ': approach too long'
for i, (n, step, duration) in enumerate(zip(c.events, rule['semitones'], rule['durations'])): 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: if n.start != cursor or n.end != cursor + fractions.Fraction(duration) or n.pitch.number() != target.pitch.number() + step:
@@ -144,7 +146,7 @@ def validate(notes, contracts, harmony):
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): 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' return {}, c.id + ': chromatic connection lacks its incoming motion'
elif c.kind == 'passing': elif c.kind == 'passing':
rule = generator.vocabulary.data('lead_ins')['passing'] rule = composition.data('lead_ins')['passing']
if c.rule != rule['id'] or len(c.events) != 1 or not c.preparation: if c.rule != rule['id'] or len(c.events) != 1 or not c.preparation:
return {}, c.id + ': unsupported passing figure' return {}, c.id + ': unsupported passing figure'
n, = c.events n, = c.events
@@ -160,7 +162,7 @@ def validate(notes, contracts, harmony):
if not supported(before) or not supported(target): if not supported(before) or not supported(target):
return {}, c.id + ': unsupported passing endpoints' return {}, c.id + ': unsupported passing endpoints'
elif c.kind == 'delayed_approach': elif c.kind == 'delayed_approach':
rule = generator.vocabulary.data('lead_ins').get('delayed_approach', {}) rule = composition.data('lead_ins').get('delayed_approach', {})
if c.rule != rule.get('id') or len(c.events) != 1: if c.rule != rule.get('id') or len(c.events) != 1:
return {}, c.id + ': unknown delayed approach' return {}, c.id + ': unknown delayed approach'
n, = c.events n, = c.events
@@ -171,7 +173,7 @@ def validate(notes, contracts, harmony):
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): 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' return {}, c.id + ': invalid delayed destination'
elif c.kind == 'color': elif c.kind == 'color':
rule = next((r for r in generator.vocabulary.data('harmony')['colors'] if r['id'] == c.rule), None) rule = next((r for r in composition.data('harmony')['colors'] if r['id'] == c.rule), None)
if not rule: if not rule:
return {}, c.id + ': unknown color policy' return {}, c.id + ': unknown color policy'
colored = False colored = False
@@ -193,10 +195,10 @@ def validate(notes, contracts, harmony):
return {}, c.id + ': no interval expresses the declared color' return {}, c.id + ': no interval expresses the declared color'
last = c.events[-1] last = c.events[-1]
if rule.get('resolve'): if rule.get('resolve'):
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): if target.event_id == last.event_id or not 0 <= target.start - last.end <= fractions.Fraction(composition.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' return {}, c.id + ': unresolved color'
elif c.kind == 'suspension': elif c.kind == 'suspension':
rule = next((r for r in generator.vocabulary.data('harmony').get('suspensions', []) if r['id'] == c.rule), None) rule = next((r for r in composition.data('harmony').get('suspensions', []) if r['id'] == c.rule), None)
if not rule or len(c.events) != 1 or c.boundary is None: if not rule or len(c.events) != 1 or c.boundary is None:
return {}, c.id + ': unsupported suspension' return {}, c.id + ': unsupported suspension'
held, = c.events held, = c.events
@@ -211,23 +213,23 @@ def validate(notes, contracts, harmony):
if held.pitch.number() % 12 not in harmony.permitted(held.start, held.end): if held.pitch.number() % 12 not in harmony.permitted(held.start, held.end):
return {}, c.id + ': suspension outside source-key/chord motion' return {}, c.id + ': suspension outside source-key/chord motion'
elif c.kind == 'anticipated_color': elif c.kind == 'anticipated_color':
rule = next((r for r in generator.vocabulary.data('harmony')['colors'] if r['id'] == c.rule), None) rule = next((r for r in composition.data('harmony')['colors'] if r['id'] == c.rule), None)
if not rule or len(c.events) != 1 or c.boundary != span.start: if not rule or len(c.events) != 1 or c.boundary != span.start:
return {}, c.id + ': unsupported anticipated color' return {}, c.id + ': unsupported anticipated color'
n, = c.events n, = c.events
if not n.start < c.boundary < n.end or c.boundary - n.start > fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']): if not n.start < c.boundary < n.end or c.boundary - n.start > fractions.Fraction(composition.data('harmony')['anticipation_max']):
return {}, c.id + ': invalid anticipated color interval' return {}, c.id + ': invalid anticipated color interval'
arrived = dataclasses.replace(n, start=c.boundary) arrived = dataclasses.replace(n, start=c.boundary)
target = arrived if c.target == n else c.target target = arrived if c.target == n else c.target
inner = generator.solo.types.ExpressionContract(c.id + ':arrived', 'color', rule['id'], (arrived,), target, span.chord) 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) _, error = validate((arrived, *((target,) if target != arrived else ())), (inner,), harmony, composition=composition)
if error: if error:
return {}, error return {}, error
elif c.kind == 'anticipation': elif c.kind == 'anticipation':
if c.rule not in ('early_chord_tone', 'early_reattack') or len(c.events) != 1 or span.start != c.boundary: 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' return {}, c.id + ': unknown anticipation or non-boundary target'
n, = c.events 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']): if c.boundary is None or not n.start < c.boundary or c.boundary - n.start > fractions.Fraction(composition.data('harmony')['anticipation_max']):
return {}, c.id + ': invalid anticipation interval' return {}, c.id + ': invalid anticipation interval'
if c.rule == 'early_chord_tone' and (n != target or not c.boundary < n.end): if c.rule == 'early_chord_tone' and (n != target or not c.boundary < n.end):
return {}, c.id + ': invalid held anticipation' return {}, c.id + ': invalid held anticipation'
@@ -238,7 +240,7 @@ def validate(notes, contracts, harmony):
elif c.kind == 'blues': elif c.kind == 'blues':
if len(c.events) != 1: if len(c.events) != 1:
return {}, c.id + ': blues treatment requires one inflected event' 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) rule = next((r for r in composition.data('lead_ins')['blues'] if r['id'] == c.rule), None)
n, = c.events n, = c.events
chord = parsed_chord(span.chord) 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: 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:
@@ -262,15 +264,16 @@ def validate(notes, contracts, harmony):
approved[n.event_id] = c approved[n.event_id] = c
return approved, '' return approved, ''
def validate_frontier(notes, contracts, harmony, until, complete): def validate_frontier(notes, contracts, harmony, until, complete, *, composition=None):
"""Validate heard prefixes and bounded future obligations separately. """Validate heard prefixes and bounded future obligations separately.
Future contract events are used ONLY to establish rule feasibility. They do Future contract events are used ONLY to establish rule feasibility. They do
not count as heard resolution; the contract stays pending until its entire not count as heard resolution; the contract stays pending until its entire
target hold is present. Missing past events and expired deadlines fail. target hold is present. Missing past events and expired deadlines fail.
""" """
composition = composition or generator.vocabulary.current()
if complete: if complete:
approved, error = validate(notes, contracts, harmony) approved, error = validate(notes, contracts, harmony, composition=composition)
return approved, (), error return approved, (), error
actual = {n.event_id: n for n in notes} actual = {n.event_id: n for n in notes}
future = dict(actual) future = dict(actual)
@@ -290,5 +293,5 @@ def validate_frontier(notes, contracts, harmony, until, complete):
return {}, (), c.id + ': missing/changed event before resolution deadline' return {}, (), c.id + ': missing/changed event before resolution deadline'
if incomplete: if incomplete:
pending.append(c) pending.append(c)
approved, error = validate(tuple(sorted(future.values(), key=lambda n: (n.start, n.event_id))), contracts, harmony) approved, error = validate(tuple(sorted(future.values(), key=lambda n: (n.start, n.event_id))), contracts, harmony, composition=composition)
return {key: c for key, c in approved.items() if key in actual}, tuple(pending), error return {key: c for key, c in approved.items() if key in actual}, tuple(pending), error
+10 -7
View File
@@ -5,14 +5,16 @@ permission follows the actual sounded interval and validated resolution.
""" """
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import functools
import fractions import fractions
import functools
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.solo.types
import generator.solo.profiles
import generator.solo.expressions import generator.solo.expressions
import generator.solo.profiles
import generator.solo.recognition import generator.solo.recognition
import generator.solo.types
import generator.vocabulary
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Roles: class Roles:
@@ -55,7 +57,8 @@ class Result:
contracts: tuple = () contracts: tuple = ()
class Harmony: class Harmony:
def __init__(self, spans, profile=None): def __init__(self, spans, profile=None, *, composition=None):
self.composition = composition or generator.vocabulary.current()
self.profile = profile or generator.solo.profiles.UNRESTRICTED self.profile = profile or generator.solo.profiles.UNRESTRICTED
self.source_spans = spans self.source_spans = spans
self.spans = [] self.spans = []
@@ -112,14 +115,14 @@ class Harmony:
expressions = tuple(dataclasses.replace(c, events=tuple(self.identified(n) for n in c.events), 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) 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()) 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) permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, complete or until is None, composition=self.composition)
if error: if error:
return Result(False, 0, error=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): 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):
expressions, error = generator.solo.recognition.recognize(notes, self, expressions) expressions, error = generator.solo.recognition.recognize(notes, self, expressions, composition=self.composition)
if error: if error:
return Result(False, 0, error=error) return Result(False, 0, error=error)
permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, True) permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, True, composition=self.composition)
if error: if error:
return Result(False, 0, error=error) return Result(False, 0, error=error)
exposed = sounded = fractions.Fraction() exposed = sounded = fractions.Fraction()
+1 -2
View File
@@ -2,11 +2,10 @@
from __future__ import annotations from __future__ import annotations
import dataclasses import dataclasses
import functools
import fractions import fractions
import functools
import generator.fretboard import generator.fretboard
import generator.vocabulary
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Instrument: class Instrument:
+14 -12
View File
@@ -5,9 +5,10 @@ Existing expression validators prove each candidate; deterministic set-cover
search chooses a mutually consistent explanation, never edits the notes. search chooses a mutually consistent explanation, never edits the notes.
""" """
import fractions import fractions
import generator.vocabulary
import generator.solo.expressions import generator.solo.expressions
import generator.solo.types import generator.solo.types
import generator.vocabulary
def signature(c): def signature(c):
return (c.kind, c.rule, tuple(n.event_id for n in c.events), c.target.event_id, return (c.kind, c.rule, tuple(n.event_id for n in c.events), c.target.event_id,
@@ -20,7 +21,8 @@ def merge(*groups):
result.setdefault(signature(c), c) result.setdefault(signature(c), c)
return tuple(result.values()) return tuple(result.values())
def recognize(notes, harmony, provided=(), *, budget=4096): def recognize(notes, harmony, provided=(), *, budget=4096, composition=None):
composition = composition or generator.vocabulary.current()
required = {n.event_id for n in notes if not harmony.ordinary(n)} 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} fixed = {n.event_id for c in provided for n in c.events}
if required <= fixed: if required <= fixed:
@@ -46,7 +48,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
if not (covered & required): if not (covered & required):
return False return False
combined = merge(provided, bundle) combined = merge(provided, bundle)
_, error = generator.solo.expressions.validate(notes, combined, harmony) _, error = generator.solo.expressions.validate(notes, combined, harmony, composition=composition)
if error: if error:
return False return False
candidates[tuple(signature(a) for a in bundle)] = (bundle, covered) candidates[tuple(signature(a) for a in bundle)] = (bundle, covered)
@@ -57,11 +59,11 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
for i, n in enumerate(notes): for i, n in enumerate(notes):
if n.event_id not in required: if n.event_id not in required:
continue continue
future = [t for t in notes[i + 1:] if n.end <= t.start <= n.end + fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap']) future = [t for t in notes[i + 1:] if n.end <= t.start <= n.end + fractions.Fraction(composition.data('harmony')['resolution_gap'])
and 0 < abs(t.pitch.number() - n.pitch.number()) <= 2] and 0 < abs(t.pitch.number() - n.pitch.number()) <= 2]
regions = [(None, harmony.at(n.start))] regions = [(None, harmony.at(n.start))]
regions.extend((s.start, s) for s in harmony.spans if n.start < s.start < n.end 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'])) and s.start - n.start <= fractions.Fraction(composition.data('harmony')['anticipation_max']))
for boundary, span in regions: for boundary, span in regions:
if not span or span.chord == 'N.C.': if not span or span.chord == 'N.C.':
continue continue
@@ -69,7 +71,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
c = contract('anticipation', 'early_chord_tone', (n,), n, boundary) c = contract('anticipation', 'early_chord_tone', (n,), n, boundary)
if add(c): if add(c):
single.setdefault(n.event_id, []).append((c,)) single.setdefault(n.event_id, []).append((c,))
for rule in generator.vocabulary.data('harmony')['colors']: for rule in composition.data('harmony')['colors']:
if not generator.solo.expressions.eligible_color(rule, span, harmony) and boundary is not None: if not generator.solo.expressions.eligible_color(rule, span, harmony) and boundary is not None:
continue continue
kind = 'anticipated_color' if boundary is not None else 'color' kind = 'anticipated_color' if boundary is not None else 'color'
@@ -85,7 +87,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
target = notes[i + 1] target = notes[i + 1]
for s in harmony.spans: for s in harmony.spans:
if n.start < s.start < n.end: if n.start < s.start < n.end:
for rule in generator.vocabulary.data('harmony').get('suspensions', []): for rule in composition.data('harmony').get('suspensions', []):
add(contract('suspension', rule['id'], (n,), target, s.start)) add(contract('suspension', rule['id'], (n,), target, s.start))
elif s.start == n.end == target.start and n.pitch.number() == target.pitch.number(): elif s.start == n.end == target.start and n.pitch.number() == target.pitch.number():
add(contract('anticipation', 'early_reattack', (n,), target, s.start)) add(contract('anticipation', 'early_reattack', (n,), target, s.start))
@@ -99,7 +101,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
targets.append((None, support)) targets.append((None, support))
elif c.kind == 'anticipation': elif c.kind == 'anticipation':
targets.append((c.boundary, support)) targets.append((c.boundary, support))
for rule in generator.vocabulary.data('lead_ins')['patterns']: for rule in composition.data('lead_ins')['patterns']:
size = len(rule['semitones']) size = len(rule['semitones'])
if i < size: if i < size:
continue continue
@@ -113,7 +115,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
add(contract(rule['kind'], rule['id'], ns, target, boundary, preparation), extras) add(contract(rule['kind'], rule['id'], ns, target, boundary, preparation), extras)
# Delayed approaches may include real space or intervening valid music. # Delayed approaches may include real space or intervening valid music.
# They still have an exact semitone target and a finite deadline. # They still have an exact semitone target and a finite deadline.
rule = generator.vocabulary.data('lead_ins')['delayed_approach'] rule = composition.data('lead_ins')['delayed_approach']
for n in reversed(notes[:i]): for n in reversed(notes[:i]):
if target.start - n.start > fractions.Fraction(rule['deadline']): if target.start - n.start > fractions.Fraction(rule['deadline']):
break break
@@ -124,8 +126,8 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
if n.event_id in required: if n.event_id in required:
for left in [(), *single.get(before.event_id, ())]: for left in [(), *single.get(before.event_id, ())]:
for right in [(), *single.get(target.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)) add(contract('passing', composition.data('lead_ins')['passing']['id'], (n,), target, preparation=before), merge(left, right))
for rule in generator.vocabulary.data('lead_ins')['blues']: for rule in composition.data('lead_ins')['blues']:
add(contract('blues', rule['id'], (n,), target, preparation=before)) 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))} options = {identifier: [] for identifier in sorted(required - fixed, key=lambda identifier: (by_id[identifier].start, identifier))}
for bundle, covered in candidates.values(): for bundle, covered in candidates.values():
@@ -152,7 +154,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
identifier = min(remaining, key=lambda x: (len(options[x]), by_id[x].start, x)) identifier = min(remaining, key=lambda x: (len(options[x]), by_id[x].start, x))
for bundle, added in options[identifier]: for bundle, added in options[identifier]:
combined = merge(chosen, bundle) combined = merge(chosen, bundle)
_, error = generator.solo.expressions.validate(notes, combined, harmony) _, error = generator.solo.expressions.validate(notes, combined, harmony, composition=composition)
if error: if error:
continue continue
result = solve(combined, covered | added) result = solve(combined, covered | added)
+11 -8
View File
@@ -1,9 +1,10 @@
"""Compact reports of actual selected phrases, not duplicated draft ancestry.""" """Compact reports of actual selected phrases, not duplicated draft ancestry."""
import dataclasses, fractions, json
import generator.solo.placement
import generator.solo.harmonic
import collections import collections
import generator.fretboard import dataclasses, fractions, json
import generator.solo.harmonic
import generator.solo.placement
import generator.vocabulary
def plain(value): def plain(value):
if dataclasses.is_dataclass(value): if dataclasses.is_dataclass(value):
@@ -16,8 +17,9 @@ def plain(value):
return str(value) return str(value)
return value return value
def structure(solo): def structure(solo, *, composition=None):
harmony = generator.solo.harmonic.Harmony(solo.harmony) composition = composition or generator.vocabulary.current()
harmony = generator.solo.harmonic.Harmony(solo.harmony, composition=composition)
contracts = tuple(c for p in solo.phrases for c in p.expressions) 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) checked = harmony.evaluate(solo.notes(), until=solo.end, complete=True, expressions=contracts)
phrases = [] phrases = []
@@ -37,8 +39,9 @@ def structure(solo):
'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_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)} 'harmonic_exposure': checked.exposure, 'unresolved_treatments': plain(checked.pending)}
def describe(solo, measure_offset=0, time_offset=0, *, detail=None): def describe(solo, measure_offset=0, time_offset=0, *, detail=None, composition=None):
detail = detail or structure(solo) composition = composition or generator.vocabulary.current()
detail = detail or structure(solo, composition=composition)
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.', 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'])] 'Approximate fingering costs are not a biomechanical guarantee.', json.dumps(detail['phrasing'])]
for p in solo.phrases: for p in solo.phrases:
@@ -1,7 +1,14 @@
"""Immutable source events, meter boundaries and tempo. No solo construction.""" """Immutable source events, meter boundaries and tempo. No solo construction."""
from __future__ import annotations from __future__ import annotations
import dataclasses, fractions import copy
import generator.model, generator.score, generator.solo.types import dataclasses
import fractions
import hashlib
import json
import generator.model
import generator.score
import generator.solo.types
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Analysis: class Analysis:
@@ -43,3 +50,32 @@ def analyze(document):
boundaries.append(boundaries[-1] + generator.model.fraction(m['duration'])) 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)) 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)]) return Analysis(tuple(boundaries), tempo, attacks(document), boundaries[generator.score.body_start_measure_index(measures)])
@dataclasses.dataclass(frozen=True)
class SourceAnalysis:
document_json: str
musical_key: str
@classmethod
def read(cls, document):
prepared = copy.deepcopy(document)
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'] = 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 = 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 accompaniment_key(self):
document = self.document()
document['metadata'].pop('tempo', None)
for measure in generator.score.source_staff(document)['measures']:
measure['tempos'] = []
return SourceAnalysis.read(document).musical_key
+2 -1
View File
@@ -6,6 +6,7 @@ import typing
import generator.model import generator.model
import generator.score import generator.score
import generator.score_io
def _arranged_staff(document: dict[str, typing.Any]) -> dict[str, typing.Any]: def _arranged_staff(document: dict[str, typing.Any]) -> dict[str, typing.Any]:
staves = document.get("staves") staves = document.get("staves")
@@ -61,7 +62,7 @@ def performance_document_summary(document: dict[str, typing.Any]) -> dict[str, t
def performance_tree_summary(root: pathlib.Path) -> dict[str, typing.Any]: def performance_tree_summary(root: pathlib.Path) -> dict[str, typing.Any]:
return { return {
path.name: performance_document_summary(generator.score.load_json(path)) path.name: performance_document_summary(generator.score_io.load_json(path))
for path in sorted(root.glob("*.json")) for path in sorted(root.glob("*.json"))
} }
+10 -11
View File
@@ -3,26 +3,25 @@ from __future__ import annotations
import argparse import argparse
import collections import collections
import dataclasses import dataclasses
import fractions
import hashlib import hashlib
import json import json
import os import os
import pathlib import pathlib
import shutil
import tempfile import tempfile
import time import time
import fractions
import generator.fretboard
import generator.harmonic_time
import generator.harmony import generator.harmony
import generator.model
import generator.musicxml import generator.musicxml
import generator.parts import generator.parts
import generator.solo.authored
import generator.solo.learned
import generator.source
import generator.training_io import generator.training_io
import generator.vocabulary 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) @dataclasses.dataclass(frozen=True)
class Span: class Span:
@@ -57,8 +56,8 @@ def load_examples(manifest):
if not source.is_relative_to((manifest.parent.parent / 'songs').resolve()): if not source.is_relative_to((manifest.parent.parent / 'songs').resolve()):
raise ValueError('Training sources must be inside data/songs') raise ValueError('Training sources must be inside data/songs')
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(source)) document = generator.parts.prepare_document(generator.musicxml.load_musicxml(source))
a = generator.solo.analysis.analyze(document) a = generator.source.analyze(document)
pair = (generator.production.SourceAnalysis.read(document).musical_key, entry['slot']) pair = (generator.source.SourceAnalysis.read(document).musical_key, entry['slot'])
if pair in pairs: if pair in pairs:
raise ValueError('Duplicate source music and solo slot') raise ValueError('Duplicate source music and solo slot')
pairs.add(pair) pairs.add(pair)
+161
View File
@@ -0,0 +1,161 @@
"""Score document validation, above pure musical values and instrument rules."""
from __future__ import annotations
import fractions
import generator.fretboard
import generator.model
import generator.pitch
def validate_document(document: dict) -> None:
if not isinstance(document, dict) or not isinstance(document.get('metadata'), dict):
raise ValueError('score requires a metadata object')
if any(not isinstance(document['metadata'].get(name), str) for name in ('title', 'composer', 'key_signature', 'time_signature')):
raise ValueError('score metadata requires title, composer, key_signature and time_signature strings')
staves = document.get('staves', [])
if not isinstance(staves, list) or len(staves) != 1 or not isinstance(staves[0], dict):
raise ValueError('exactly one staff is currently supported')
if not isinstance(staves[0].get('measures'), list) or not staves[0]['measures']:
raise ValueError('score requires measures')
instruments = staves[0].get('instruments', [])
instrument_ids = {i['id'] for i in instruments}
if len(instruments) != len(instrument_ids) or any(not isinstance(i, str) or not i for i in instrument_ids):
raise ValueError('score instruments require unique nonempty IDs')
ids: set[str] = set()
events: dict[str, dict] = {}
tones: dict[str, tuple[dict, fractions.Fraction, fractions.Fraction, str]] = {}
ownership: dict[str, str] = {}
absolute = fractions.Fraction()
for measure in staves[0]['measures']:
if {'repeats', 'endings', 'initial_attributes'} & measure.keys():
raise ValueError('repeat notation is unsupported; regenerate scores from music written out in reading order')
length = generator.model.fraction(measure['duration'])
if length <= 0:
raise ValueError('measure duration must be positive')
voices = measure.get('voices', [])
if not voices or len({v['id'] for v in voices}) != len(voices) or any(not v['id'] for v in voices):
raise ValueError('measure requires uniquely identified voice lanes')
previous_ends = {v['id']: fractions.Fraction() for v in voices}
for voice, note in ((v, n) for v in voices for n in v['notes']):
if any({'sound_ties', 'printed_ties'} & pitch.keys() for pitch in note['pitches']):
raise ValueError('pass-specific tie metadata is unsupported; regenerate scores')
if not note['id'] or note['id'] in ids:
raise ValueError(f'duplicate event ID: {note["id"]}')
ids.add(note['id'])
events[note['id']] = note
ownership[note['id']] = voice['id']
if note.get('voice', voice['id']) != voice['id']:
raise ValueError('event voice ownership disagrees with its lane')
onset, duration = generator.model.fraction(note['onset']), generator.model.fraction(note['duration'])
if onset < 0 or duration < 0 or onset + duration > length:
raise ValueError(f'event outside measure: {note["id"]}')
if note['kind'] not in ('note', 'rest', 'spacer'):
raise ValueError('invalid event kind')
if not note.get('grace'):
if duration == 0 or onset < previous_ends[voice['id']]:
raise ValueError('overlapping events in one rhythmic voice')
previous_ends[voice['id']] = onset + duration
elif duration != 0:
raise ValueError('grace notes must not advance metric time')
expected = generator.model.metric_length(note['written'])
if not note.get('grace') and not note.get('measure_rest') and duration != expected:
raise ValueError('metric and written event durations disagree')
if note['kind'] != 'note' and note.get('pitches'):
raise ValueError('rests and spacers cannot contain pitches')
if note['kind'] == 'note' and not note.get('pitches') and note.get('notehead') != 'slash':
raise ValueError('pitched event requires at least one pitch')
for p in note.get('pitches', []):
if p.get('instrument') and p['instrument'] not in instrument_ids:
raise ValueError('note refers to an unknown score instrument')
if 'unpitched' in p:
position = p['unpitched']
if position['letter'] not in generator.model.LETTERS or type(position['octave']) is not int:
raise ValueError('invalid unpitched display position')
else:
generator.model.pitch_number(p)
if staves[0].get('tablature'):
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:
raise ValueError(f'{p["id"]}: invalid generated tablature position')
state = dict(measure)
for change in measure.get('attributes', []):
if generator.model.fraction(change['onset']) <= onset:
state.update(change)
if dict(generator.fretboard.STANDARD_TUNING)[string] + fret != generator.pitch.sounding_pitch(p, state):
raise ValueError(f'{p["id"]}: tablature disagrees with sounding pitch')
pd = generator.model.fraction(p['duration'])
if pd < 0 or (pd == 0 and not note.get('grace')) or pd > duration or not p['id'] or p['id'] in tones:
raise ValueError('invalid chord-tone duration or duplicate ID')
if not note.get('grace') and pd != generator.model.metric_length(p['written']):
raise ValueError('metric and written chord-tone durations disagree')
tones[p['id']] = (p, absolute + onset, pd, voice['id'])
for harmony in measure.get('harmonies', []):
if not 0 <= generator.model.fraction(harmony['onset']) < length:
raise ValueError('harmony outside measure')
for change in measure.get('attributes', []):
if not 0 <= generator.model.fraction(change['onset']) < length:
raise ValueError('attribute change outside measure')
for state in [measure, *measure.get('attributes', [])]:
if 'meter' in state:
generator.model.validate_meter(state['meter'])
if 'key' in state:
if len(state['key']['alterations']) != 7:
raise ValueError('key alterations must contain seven exact alterations')
for value in state['key']['alterations']:
generator.model.alteration(value)
if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C', 'percussion', 'none') or not 1 <= state['clef']['line'] <= 6 or not -3 <= state['clef']['octave_change'] <= 3):
raise ValueError('invalid clef')
absolute += length
for identifier, (p, start, duration, voice) in tones.items():
if target := p.get('tie_to'):
if target not in tones:
raise ValueError(f'dangling tie: {identifier} -> {target}')
other, next_start, _, next_voice = tones[target]
same_pitch = all(p.get(k) == other.get(k) for k in ('unpitched', 'instrument')) if 'unpitched' in p else all(p.get(k) == other.get(k) for k in ('letter', 'alter', 'octave'))
if other.get('tie_from') != identifier or not same_pitch or next_voice != voice or next_start != start + duration:
raise ValueError(f'invalid tie: {identifier} -> {target}')
if staves[0].get('tablature') and p['tablature'] != other['tablature']:
raise ValueError(f'{identifier}: tied guitar tones must retain their string and fret')
if source := p.get('tie_from'):
if source not in tones or tones[source][0].get('tie_to') != identifier:
raise ValueError(f'dangling incoming tie: {identifier}')
span_ids = set()
event_order = {identifier: index for index, identifier in enumerate(events)}
for span in staves[0].get('tuplets', []):
if not span['id'] or span['id'] in span_ids or span['start'] not in ids or span['end'] not in ids or not 0 < span['note_count'] <= 1000000 or not 0 < span['space_count'] <= 1000000 or not 0 <= span['depth'] <= 16:
raise ValueError('invalid tuplet span')
members = [n['id'] for n in events.values() if span['id'] in n.get('tuplet_ids', [])]
if not members or members[0] != span['start'] or members[-1] != span['end'] or event_order[span['start']] > event_order[span['end']]:
raise ValueError('tuplet endpoints disagree with member events')
if len({ownership[n] for n in members}) != 1 or span.get('voice', ownership[members[0]]) != ownership[members[0]]:
raise ValueError('tuplet crosses independent voice lanes')
if span.get('bracket', 'auto') not in ('yes', 'no', 'auto') or span.get('show_number', 'actual') not in ('none', 'actual', 'both'):
raise ValueError('invalid tuplet appearance')
span_ids.add(span['id'])
for note in events.values():
for verse in note.get('lyrics', []):
start = tones[note['pitches'][0]['id']][1] if note['pitches'] else fractions.Fraction()
if 'extend_to' in verse and not start < generator.model.fraction(verse['extend_to']) <= absolute:
raise ValueError('lyric extension endpoint is outside the score')
if any(t not in span_ids for t in note.get('tuplet_ids', [])):
raise ValueError('event refers to an unknown tuplet')
if tremolo := note.get('tremolo'):
if tremolo['type'] not in ('single', 'start', 'stop') or type(tremolo['marks']) is not int or not 0 <= tremolo['marks'] <= 8:
raise ValueError('invalid measured tremolo')
if tremolo['type'] in ('start', 'stop'):
other = events.get(tremolo.get('partner'))
expected_type = 'stop' if tremolo['type'] == 'start' else 'start'
if not other or other.get('tremolo', {}).get('partner') != note['id'] or other['tremolo']['type'] != expected_type or other['tremolo']['marks'] != tremolo['marks']:
raise ValueError('invalid paired tremolo')
for span in staves[0].get('octave_shifts', []):
if not 0 <= generator.model.fraction(span['start']) < generator.model.fraction(span['end']) <= absolute or not -3 <= span['octaves'] <= 3:
raise ValueError('invalid octave-shift span')
previous_end = fractions.Fraction()
for span in staves[0].get('scale_spans', []):
start, end = generator.model.fraction(span['start']), generator.model.fraction(span['end'])
if not previous_end <= start < end <= absolute or not isinstance(span.get('label'), str) or not span['label'].strip() or type(span.get('matched')) is not bool:
raise ValueError('invalid solo scale span')
if not isinstance(span.get('id'), str) or not span['id'] or span['id'] in ids:
raise ValueError('solo scale spans require unique IDs')
ids.add(span['id'])
previous_end = end
+39 -9
View File
@@ -2,12 +2,14 @@
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import contextvars import contextvars
import copy
import dataclasses import dataclasses
import fractions
import functools import functools
import hashlib import hashlib
import json import json
import pathlib import pathlib
import fractions import types
ROOT = pathlib.Path(__file__).resolve().parents[1] / 'data' / 'composition' ROOT = pathlib.Path(__file__).resolve().parents[1] / 'data' / 'composition'
ACTIVE = contextvars.ContextVar('composition_vocabulary', default=None) ACTIVE = contextvars.ContextVar('composition_vocabulary', default=None)
@@ -16,7 +18,7 @@ def load(root=ROOT):
# Content, not just the directory, keys memoization. Long-lived individual # Content, not just the directory, keys memoization. Long-lived individual
# role builds must observe edited production data without restarting Python. # role builds must observe edited production data without restarting Python.
names = ('roles', 'harmony', 'lead_ins', 'accompaniment') names = ('roles', 'harmony', 'lead_ins', 'accompaniment')
return _load(tuple((name, (pathlib.Path(root) / (name + '.json')).read_text()) for name in names)) return copy.deepcopy(_load(tuple((name, (pathlib.Path(root) / (name + '.json')).read_text()) for name in names)))
@functools.lru_cache(maxsize=16) @functools.lru_cache(maxsize=16)
def _load(contents): def _load(contents):
@@ -105,26 +107,54 @@ def _load(contents):
@functools.lru_cache(maxsize=1) @functools.lru_cache(maxsize=1)
def _default(): def _default():
return {'data': load(), 'role': '', 'settings': {}} return CompositionContext.create(load())
def _freeze(value):
if isinstance(value, dict):
return types.MappingProxyType({key: _freeze(item) for key, item in value.items()})
if isinstance(value, list):
return tuple(_freeze(item) for item in value)
return value
@functools.lru_cache(maxsize=32)
def _frozen_json(contents):
return _freeze(json.loads(contents))
@dataclasses.dataclass(frozen=True)
class CompositionContext:
"""Owned, immutable, pickleable policy inputs with stable iteration order."""
resources_json: str
name: str = ''
settings_json: str = '{}'
@classmethod
def create(cls, resources, name='', settings=None):
return cls(json.dumps(resources), name, json.dumps(settings or {}))
def data(self, name):
return _frozen_json(self.resources_json)[name]
def role(self, name=None):
return {**self.data('roles')['roles'].get(name or self.name, {}), **_frozen_json(self.settings_json)}
def accompaniment(self, part):
value = self.data('accompaniment')
return {**{key: item for key, item in value.items() if key != 'instruments'}, **value['instruments'][part]}
def current(): def current():
return ACTIVE.get() or _default() return ACTIVE.get() or _default()
def data(name): def data(name):
return current()['data'][name] return current().data(name)
def accompaniment(part, resources=None): def accompaniment(part, resources=None):
value = (resources or current()['data'])['accompaniment'] if resources is None:
return current().accompaniment(part)
value = resources['accompaniment']
return {**{k: v for k, v in value.items() if k != 'instruments'}, **value['instruments'][part]} return {**{k: v for k, v in value.items() if k != 'instruments'}, **value['instruments'][part]}
def role(name=None): def role(name=None):
active = current() return current().role(name)
name = name or active['role']
return {**active['data']['roles']['roles'].get(name, {}), **active['settings']}
@contextlib.contextmanager @contextlib.contextmanager
def using(resources, name, settings=None): def using(resources, name, settings=None):
token = ACTIVE.set({'data': resources, 'role': name, 'settings': settings or {}}) token = ACTIVE.set(CompositionContext.create(resources, name, settings))
try: try:
yield yield
finally: finally:
+2 -1
View File
@@ -25,7 +25,8 @@ public:
// An already-running completion may overlap stop; capture its consumer weakly. // An already-running completion may overlap stop; capture its consumer weakly.
score_loader(boost::asio::any_io_executor executor, score_loader(boost::asio::any_io_executor executor,
std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load,
std::function<void()> completed = {}); std::function<void()> completed = {},
std::optional<boost::asio::any_io_executor> worker = {});
~score_loader(); ~score_loader();
score_loader(score_loader const &) = delete; score_loader(score_loader const &) = delete;
auto operator=(score_loader const &) -> score_loader & = delete; auto operator=(score_loader const &) -> score_loader & = delete;
+2 -1
View File
@@ -4,6 +4,7 @@
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <optional>
#include <string> #include <string>
#include <boost/asio/any_io_executor.hpp> #include <boost/asio/any_io_executor.hpp>
@@ -24,7 +25,7 @@ class score_loader;
class zero_ui { class zero_ui {
public: public:
zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler = {}, std::function<void(vamp_zero::menu_action)> menu_handler = {}); zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler = {}, std::function<void(vamp_zero::menu_action)> menu_handler = {}, std::optional<boost::asio::any_io_executor> score_worker = {});
~zero_ui(); ~zero_ui();
zero_ui(zero_ui const &) = delete; zero_ui(zero_ui const &) = delete;
auto operator=(zero_ui const &) -> zero_ui & = delete; auto operator=(zero_ui const &) -> zero_ui & = delete;
+5 -2
View File
@@ -22,6 +22,7 @@
#include <boost/asio/post.hpp> #include <boost/asio/post.hpp>
#include <boost/asio/signal_set.hpp> #include <boost/asio/signal_set.hpp>
#include <boost/asio/steady_timer.hpp> #include <boost/asio/steady_timer.hpp>
#include <boost/asio/thread_pool.hpp>
#include <boost/asio/use_future.hpp> #include <boost/asio/use_future.hpp>
#include <boost/json/object.hpp> #include <boost/json/object.hpp>
#include <boost/json/serialize.hpp> #include <boost/json/serialize.hpp>
@@ -322,6 +323,7 @@ private:
auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &state, vamp_zero::audio_controller *audio) -> std::expected<void, modulus::error> { auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &state, vamp_zero::audio_controller *audio) -> std::expected<void, modulus::error> {
auto io = boost::asio::io_context{}; auto io = boost::asio::io_context{};
auto score_worker = boost::asio::thread_pool{1};
auto endpoint = make_server_endpoint(config); auto endpoint = make_server_endpoint(config);
if (!endpoint) { if (!endpoint) {
return std::unexpected(endpoint.error()); return std::unexpected(endpoint.error());
@@ -335,7 +337,7 @@ auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &sta
if (audio != nullptr) { if (audio != nullptr) {
audio->sync(state.snapshot()); audio->sync(state.snapshot());
} }
auto loader = std::make_shared<vamp_zero::score_loader>(io.get_executor(), [executor = io.get_executor(), database = config.database, assets = config.runtime.assets_path](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); }); auto loader = std::make_shared<vamp_zero::score_loader>(io.get_executor(), [executor = score_worker.get_executor(), database = config.database, assets = config.runtime.assets_path](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); }, std::function<void()>{}, score_worker.get_executor());
auto playback = playback_poll{io, state, audio, loader, config.runtime.assets_path, true}; auto playback = playback_poll{io, state, audio, loader, config.runtime.assets_path, true};
auto heartbeat = write_heartbeat(config); auto heartbeat = write_heartbeat(config);
if (!heartbeat) { if (!heartbeat) {
@@ -354,11 +356,12 @@ auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &sta
auto run_display(vamp_zero::config const &config, vamp_zero::control_state &state, vamp_zero::audio_controller *audio) -> std::expected<void, modulus::error> { auto run_display(vamp_zero::config const &config, vamp_zero::control_state &state, vamp_zero::audio_controller *audio) -> std::expected<void, modulus::error> {
auto io = boost::asio::io_context{}; auto io = boost::asio::io_context{};
auto score_worker = boost::asio::thread_pool{1};
auto endpoint = make_server_endpoint(config); auto endpoint = make_server_endpoint(config);
if (!endpoint) { if (!endpoint) {
return std::unexpected(endpoint.error()); return std::unexpected(endpoint.error());
} }
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, config.runtime.assets_path, [&state](vamp_protocol::page_command command) { static_cast<void>(state.apply(command)); }, [&state](vamp_zero::menu_action action) { static_cast<void>(state.apply(action)); }}; auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, config.runtime.assets_path, [&state](vamp_protocol::page_command command) { static_cast<void>(state.apply(command)); }, [&state](vamp_zero::menu_action action) { static_cast<void>(state.apply(action)); }, score_worker.get_executor()};
state.set_observer([&ui](vamp_zero::app_state app) { state.set_observer([&ui](vamp_zero::app_state app) {
auto status = ui.publish(std::move(app)); auto status = ui.publish(std::move(app));
if (!status) { if (!status) {
+11 -9
View File
@@ -6,6 +6,7 @@
#include <utility> #include <utility>
#include <boost/asio/co_spawn.hpp> #include <boost/asio/co_spawn.hpp>
#include <boost/asio/bind_executor.hpp>
#include <boost/asio/post.hpp> #include <boost/asio/post.hpp>
#include <vamp_zero/errors.hpp> #include <vamp_zero/errors.hpp>
@@ -13,8 +14,8 @@
struct vamp_zero::score_loader::state : std::enable_shared_from_this<state> { struct vamp_zero::score_loader::state : std::enable_shared_from_this<state> {
state(boost::asio::any_io_executor executor, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, std::function<void()> completed) state(boost::asio::any_io_executor executor, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, std::function<void()> completed, std::optional<boost::asio::any_io_executor> worker)
: executor{std::move(executor)}, load{std::move(load)}, completed{std::move(completed)} {} : executor{executor}, worker{worker.value_or(executor)}, load{std::move(load)}, completed{std::move(completed)} {}
static auto run(std::weak_ptr<state> weak, std::uint64_t generation, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> { static auto run(std::weak_ptr<state> weak, std::uint64_t generation, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> {
{ {
auto self = weak.lock(); auto self = weak.lock();
@@ -63,15 +64,16 @@ struct vamp_zero::score_loader::state : std::enable_shared_from_this<state> {
auto start() -> void { auto start() -> void {
in_flight = true; in_flight = true;
// post is required: co_spawn can enter inline on an already-running executor. // post is required: co_spawn can enter inline on an already-running executor.
boost::asio::post(executor, [weak = weak_from_this(), generation = generation, load = load, boost::asio::post(worker, [weak = weak_from_this(), generation = generation, load = load,
reference = *reference, executor = executor]() mutable { reference = *reference, executor = executor, worker = worker]() mutable {
boost::asio::co_spawn(executor, run(weak, generation, std::move(load), std::move(reference)), boost::asio::co_spawn(worker, run(weak, generation, std::move(load), std::move(reference)),
[weak, generation](std::exception_ptr exception, std::expected<vamp_zero::score_document, modulus::error> loaded) { boost::asio::bind_executor(executor, [weak, generation](std::exception_ptr exception, std::expected<vamp_zero::score_document, modulus::error> loaded) {
finish(weak, generation, exception, std::move(loaded)); finish(weak, generation, exception, std::move(loaded));
}); }));
}); });
} }
boost::asio::any_io_executor executor; boost::asio::any_io_executor executor;
boost::asio::any_io_executor worker;
std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load; std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load;
std::function<void()> completed; std::function<void()> completed;
std::mutex mutex{}; std::mutex mutex{};
@@ -83,8 +85,8 @@ struct vamp_zero::score_loader::state : std::enable_shared_from_this<state> {
std::bool_t stopped{}; std::bool_t stopped{};
}; };
vamp_zero::score_loader::score_loader(boost::asio::any_io_executor executor, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, std::function<void()> completed) vamp_zero::score_loader::score_loader(boost::asio::any_io_executor executor, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, std::function<void()> completed, std::optional<boost::asio::any_io_executor> worker)
: state_{std::make_shared<state>(std::move(executor), std::move(load), std::move(completed))} {} : state_{std::make_shared<state>(std::move(executor), std::move(load), std::move(completed), std::move(worker))} {}
vamp_zero::score_loader::~score_loader() { vamp_zero::score_loader::~score_loader() {
stop(); stop();
+3 -3
View File
@@ -184,13 +184,13 @@ private:
} }
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler, std::function<void(vamp_zero::menu_action)> menu_handler) vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler, std::function<void(vamp_zero::menu_action)> menu_handler, std::optional<boost::asio::any_io_executor> score_worker)
: application_{std::make_shared<luma::application>(std::move(config))}, : application_{std::make_shared<luma::application>(std::move(config))},
runtime_{std::make_shared<vamp_zero::ui_runtime>(executor, std::move(command_handler), std::move(menu_handler))}, runtime_{std::make_shared<vamp_zero::ui_runtime>(executor, std::move(command_handler), std::move(menu_handler))},
score_loader_{std::make_shared<vamp_zero::score_loader>(executor, [executor, database = std::move(database), assets = std::move(assets_path)](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); }, [weak = std::weak_ptr{application_}] { score_loader_{std::make_shared<vamp_zero::score_loader>(executor, [executor = score_worker.value_or(executor), database = std::move(database), assets = std::move(assets_path)](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); }, [weak = std::weak_ptr{application_}] {
if (auto application = weak.lock()) { if (auto application = weak.lock()) {
static_cast<void>(application->request_layout()); static_cast<void>(application->request_layout());
} })} {} } }, score_worker)} {}
vamp_zero::zero_ui::~zero_ui() { vamp_zero::zero_ui::~zero_ui() {
stop(); stop();
+5
View File
@@ -61,6 +61,11 @@ add_test(
# Complete deterministic phrase searches make this suite CPU-sensitive; # Complete deterministic phrase searches make this suite CPU-sensitive;
# leave headroom for concurrent asset generation on development machines. # leave headroom for concurrent asset generation on development machines.
set_tests_properties(product_vamp_generator PROPERTIES TIMEOUT 600) set_tests_properties(product_vamp_generator PROPERTIES TIMEOUT 600)
add_test(
NAME product_vamp_assets
COMMAND ${VAMP_PYTHON_COMMAND} -m tests.asset_contract ${CMAKE_BINARY_DIR}/generated_assets
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
)
add_custom_target(check add_custom_target(check
COMMAND ${CMAKE_CTEST_COMMAND} COMMAND ${CMAKE_CTEST_COMMAND}
--test-dir ${CMAKE_BINARY_DIR} --test-dir ${CMAKE_BINARY_DIR}
+17
View File
@@ -0,0 +1,17 @@
"""Compare complete generated assets with the reviewed, pinned output baseline."""
import hashlib
import json
import pathlib
import sys
def verify(root):
expected = json.loads(pathlib.Path(__file__).with_name('fixtures').joinpath('assets_manifest.json').read_text())
actual = {path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest()
for path in sorted(root.rglob('*')) if path.is_file() and path.relative_to(root).parts[0] != 'analysis'}
changed = sorted(name for name in expected.keys() | actual.keys() if expected.get(name) != actual.get(name))
if changed:
raise AssertionError('Generated assets differ from the reviewed baseline:\n' + '\n'.join(changed))
print(f'All {len(expected)} generated assets match the reviewed baseline.')
if __name__ == '__main__':
verify(pathlib.Path(sys.argv[1]))
+411
View File
@@ -0,0 +1,411 @@
{
"backing/all_of_me_120.wav": "5104097be1d4ed0459dacedb61c2509effcfe0e26025b24574291a3467962f3d",
"backing/autumn_leaves_120.wav": "990e0218f9a09161a7a8a255bd1e64daf9fd01ac41935faa273228906775d016",
"catalog.db": "0891794877e06d201e1e149ea82f7bfdc880baf35c78a29b7b724f6c0ecce1e9",
"fonts/petaluma/OFL.txt": "5789e74d38031e93a4fbdb0a72392d1fd58cf15c29da3efa695c5099433ad0ce",
"fonts/petaluma/PetalumaScript.otf": "3b4be972cc9f857a7c1019ff49cd07cb083d5cc99058c0fb8563897fc75c32c8",
"pico/titles/42076b2eaa61a6fb408eeaec133b130967cf2c32981a9444dde6903ed7771fc8.mask": "42076b2eaa61a6fb408eeaec133b130967cf2c32981a9444dde6903ed7771fc8",
"pico/titles/8ed7df6668e9e4a24c148ba0fe9ac57209e504ca6a7e8a9b29179c097076ecae.mask": "8ed7df6668e9e4a24c148ba0fe9ac57209e504ca6a7e8a9b29179c097076ecae",
"print/all_of_me_acoustic.pdf": "aff0697fa82bde5283f705395028a3e4f6e87d4e0f1eab271dea9cffdb6c72e2",
"print/all_of_me_electric.pdf": "1185ece1e8f507d6fd7046e9d78fcb28b9e0a667c1027304abcb13c8e0519b97",
"print/all_of_me_vocal.pdf": "0ff78cb9d315dff93e7a88209610e959fa804c480cf63a8c4fb018a17c46ff66",
"print/autumn_leaves_acoustic.pdf": "badafe4d5484ab7e8a7c94633174d2233233436318fcf62bfcc043f624d097d6",
"print/autumn_leaves_electric.pdf": "a7416f9c4390072f74736024162172340d273de5108681bdf6543c9b8fa69d60",
"print/autumn_leaves_vocal.pdf": "78cc66dd27c7088e196f917eb752266989de13c51361692195b6bb9765210001",
"songs/all_of_me_acoustic.json": "b5f9f3781a79a351a59bbb54a333cbb1dac9e21c46a33c4296d6baef0ed8ff41",
"songs/all_of_me_acoustic.pages/0001.png": "165e827c3a05c1ee2aa1e5fd41c9dd2b273dac04e067df6d3e136f16fd2255a1",
"songs/all_of_me_acoustic.pages/0002.png": "b8738a70ad3d32911a8147ff18e62e4aa26651277562c348cb28f9180f045888",
"songs/all_of_me_acoustic.pages/0003.png": "af9631bdb1736de168527ac7a881de5c7b9fa1fe532aa10bffe4b4419cb67297",
"songs/all_of_me_acoustic.pages/0004.png": "8b2fdc7a064945bdfafb77b9a5f87313a86ef759a87daedb861cd3822dd2fdd2",
"songs/all_of_me_acoustic.pages/0005.png": "c11b85ac3affe9cee9184c126dd9cbffea5ef01d5f238440f831ce80b477f09b",
"songs/all_of_me_acoustic.pages/0006.png": "28bcdd62274aa79a6626935279dd88a9c85223f9ee3e4e7e914cd2ff56c704b6",
"songs/all_of_me_acoustic.pages/0007.png": "bfe462e693c5eb849b53463b2d64ba201e11196f64d5ec94e7d80a00f437d95f",
"songs/all_of_me_acoustic.pages/0008.png": "a7fa969178c12295a31429289b6d7ec273cc6e388343c174793d503856084c2c",
"songs/all_of_me_acoustic.pages/0009.png": "19a2fb51af8e4e09dafcba21b4e2277052125bb34222d2b1638a75582266bcea",
"songs/all_of_me_acoustic.pages/0010.png": "d423c4a0ae3c9d5e626bf8a633b202501ea5768c83ddbf68a6df8265d2a88315",
"songs/all_of_me_acoustic.pages/0011.png": "5e21353bfac521f66b49b6cd4e0dc00c4e85c27bac41d69809b8f88f3e9be310",
"songs/all_of_me_acoustic.pages/0012.png": "c36c7a82d1900f5394b1af3ab0bf663055e2bd7a1788fd60f416d7ec17902c7a",
"songs/all_of_me_acoustic.pages/0013.png": "063ba3b58613d9c383e379b0c6fa9b2d8b620cff5ff4def145fca918fa80f5e0",
"songs/all_of_me_acoustic.pages/0014.png": "ae3622cc934dbc126bff80f02bda6159edabd5044b5fc6c2448161652dc983ad",
"songs/all_of_me_acoustic.pages/0015.png": "99acae22eadaacd195fd986ae31f10c32800972e4d164f82cb5151ee5691c030",
"songs/all_of_me_acoustic.pages/0016.png": "c73a7111028d2e875ed441c54f9299b93947f53738e1815cb463d8a84fb7ef19",
"songs/all_of_me_acoustic.pages/0017.png": "6fa69caf6a91a38b3da2c57c146f4e79be712237b54910afe22d504cb7b46004",
"songs/all_of_me_acoustic.pages/0018.png": "f92e147f22c688b8936a7f2589b8ad6b7337e7b0aa9d9136641cff269623945b",
"songs/all_of_me_acoustic.pages/0019.png": "de5094eb5c2809146af4dc5470d58ab8ae70feb138d6fca83aa837aeb5bacc36",
"songs/all_of_me_acoustic.pages/0020.png": "d7c14a96a210c03918ad1c42240b34b27baf3412d03a443a0e51ffc631b27569",
"songs/all_of_me_acoustic.pages/0021.png": "81435e1c822309d48cd80a454a468e595f98684b8fc13e9aab61e7f9efbc8326",
"songs/all_of_me_acoustic.pages/0022.png": "78e7d1cc5b05ea1dd59b396d3554ee6b335b30d4adfc92d9da7deb80e420865c",
"songs/all_of_me_acoustic.pages/0023.png": "ed8a533012b886e46430d25b21d2a7e6ff026399f1f04a6561d8b8c465e7edf8",
"songs/all_of_me_acoustic.pages/0024.png": "d1ef3d847cc874f50fe1f7e84acba316431a56ef04101371c5eeefb38e03f54d",
"songs/all_of_me_acoustic.pages/0025.png": "6c22754c4af52c0bc1b986a9bb40d2611eaf19b891686d4fdd7082a7b37ae700",
"songs/all_of_me_acoustic.pages/0026.png": "f7433c965a235e41952d8d259c9aea0630792fe0470360208eba22d50ca2bc12",
"songs/all_of_me_acoustic.pages/0027.png": "2a7f5cb133d68b538f4ffb6e99a531a0416dcc62b8b35b9fdd002ada3277474a",
"songs/all_of_me_acoustic.pages/0028.png": "7c5a369f5dc4acef1bcc270543d6d8075a7066ceafa1cf2fabeca0fb78bcd9f7",
"songs/all_of_me_acoustic.pages/0029.png": "b58871c97069f13a3d5af60473af5aa5fb081e3373424b7761ca6f312f06e45b",
"songs/all_of_me_acoustic.pages/playback-0002.png": "89275aa531c7787a33b1450a8e84afbdb7d952a42f0b2105b58fca57243ed8d6",
"songs/all_of_me_acoustic.pages/playback-0003.png": "838b2d12000f748c0fd102de50eaea0ca3950cef07c051c6657d81f628169297",
"songs/all_of_me_acoustic.pages/playback-0004.png": "439e1856d32875c83d8d156bc9aa9f11bc493564bb342cf99044bf578980e25e",
"songs/all_of_me_acoustic.pages/playback-0005.png": "7789a952684a77309e19ca867a5500928fc96f67f5f82f371b346c9153778e96",
"songs/all_of_me_acoustic.pages/playback-0006.png": "ced45cec6c95fb0616552be40cf0543f9ab730ba0cf2414e72b156c12a74a9bc",
"songs/all_of_me_acoustic.pages/playback-0007.png": "2ed7cca6a962fead8ffcc95361aa3549e664038d99be1ec3d258ed51e917a53d",
"songs/all_of_me_acoustic.pages/playback-0008.png": "3efcd0671de4d386b189181ee7ebe03d4c50eddcfa9080c07a99c4642d4d58aa",
"songs/all_of_me_acoustic.pages/playback-0009.png": "cc4dc2ccfccda12074d69452e8e0427d40e6a0c9fb3b3da8308ab3b84240dbb2",
"songs/all_of_me_acoustic.pages/playback-0010.png": "fca310b66181635a06f6da5150a7e6ea13ce169aae94fe722139c371f5bbe1b6",
"songs/all_of_me_acoustic.pages/playback-0011.png": "dc92c989d193387eb5cc690b8274e57326eea652fa18688e37d405ca69cef051",
"songs/all_of_me_acoustic.pages/playback-0012.png": "7043e31820e325d716e7fa77992d4e416a7c9b8cf6391fc87e71ae6c685a75f8",
"songs/all_of_me_acoustic.pages/playback-0013.png": "67fbc90a1d4c3b2455bae0d0fb8f035430ad8cfed8f45a3a87988116cad5aeea",
"songs/all_of_me_acoustic.pages/playback-0014.png": "93561857be092664c798e2dd04cc6c15284c2a2e1759a056a978242f3b86e3ab",
"songs/all_of_me_acoustic.pages/playback-0015.png": "1ae5794742afae872c34f3bf1d21f3f6276d8ede4cca737ee5b58bc0a6c4b627",
"songs/all_of_me_acoustic.pages/playback-0016.png": "99274eea14c17d69f7fa940cc2dd14148e1406f4b3ed37fbabe72e1edcbc09b9",
"songs/all_of_me_acoustic.pages/playback-0017.png": "ac38a8f3c94e7a331516c90a46daa435f5c820a7db9527895dfccb2c4f2d4b3d",
"songs/all_of_me_acoustic.pages/playback-0018.png": "02c1308be295df3a8e1464e8809acbf9fb283d5e0bc400d30c99cba6f383f4d4",
"songs/all_of_me_acoustic.pages/playback-0019.png": "b009489f22aea8f68c8ad4ae0e09f7244c75e330245032ffe31bcf4812bf14b3",
"songs/all_of_me_acoustic.pages/playback-0020.png": "5a57c46ec799b8588ac42d7cf5bc98d5597302bde4d75745be343428cd4cbf74",
"songs/all_of_me_acoustic.pages/playback-0021.png": "184d0d6fbf9864dd2e3ab2a947eb21fa0ba6ef8ffc19f4ff4e05902b640c48d9",
"songs/all_of_me_acoustic.pages/playback-0022.png": "d4edcc5eb21d4b1fd97110a4e063cd1997f9f490012ea15f54f2a54ff71f0533",
"songs/all_of_me_acoustic.pages/playback-0023.png": "4fc5208032155c47172d7868529876b798596f73c9cca31bf68f4d7a63fa5660",
"songs/all_of_me_acoustic.pages/playback-0024.png": "5451f8652c7b7966d20db24fc07229dbc8839b63b04f318a7682c63e04216287",
"songs/all_of_me_acoustic.pages/playback-0025.png": "2b607bd202d85aef6d9b87d51a0cf16c6d15a14ab00ed4e894be7bdd9e5cfb77",
"songs/all_of_me_acoustic.pages/playback-0026.png": "a741f9177b792d6392eeaa1dc098df7f503e3a829fc862dabf0fc89a2334d6f1",
"songs/all_of_me_acoustic.pages/playback-0027.png": "2bf42f6fc15e0941c54f9f098f4983f2f45d9a7567eaa604a6b6319c0ce31c19",
"songs/all_of_me_acoustic.pages/playback-0028.png": "bead93f269e3ea6a91a0e71a03eb3e386c8a0f972034e7e3e23cf8807de2e589",
"songs/all_of_me_acoustic.pages/playback-0029.png": "196dd7c0f49190342cbcf4e88267462ea4fd353949f1a66fba0d129349579f85",
"songs/all_of_me_acoustic.pages/playback-0030.png": "339afb9ccf4e5fc0b18205aa3154ec0494d873494245f14512d1c77e7acdb3f2",
"songs/all_of_me_acoustic.pages/playback-0031.png": "f30c7d9824cf9eee91050879ee1b406e608d47461fa5de720639ce060ac10368",
"songs/all_of_me_acoustic.pages/playback-0032.png": "421170df4fdb0b746077ba2959ff33480c05edfaffc2505e7ebcf51a9aecf2ca",
"songs/all_of_me_acoustic.pages/playback-0033.png": "2af68de5667ca9b8d738703c562c0338e8c2fb5face892d07eb2894cb7b8e6c8",
"songs/all_of_me_acoustic.pages/playback-0034.png": "da78e34c05b7527c636124a349a38a95201173ca59d5778a88b7ad19fd458973",
"songs/all_of_me_acoustic.pages/playback-0035.png": "d3b699e968ef04d32f3330a4af99e4722f0bf436daaa818e16ea8076dd35968f",
"songs/all_of_me_acoustic.pages/playback-0036.png": "48c5688c1368946da513361c0e241ad13157fe7cf4dcaec216fe85031e63455f",
"songs/all_of_me_acoustic.pages/playback-0037.png": "e11a4272fe7596b73ac1ed2029c259268c0255bdf4805026fd28412c955a9b88",
"songs/all_of_me_acoustic.pages/playback-0038.png": "2ed4ea3113dfdf04f5416154904fc2bd409024e6beca62823bf8d49306f9dd32",
"songs/all_of_me_acoustic.pages/playback-0039.png": "506e04f4080141b20b51122756e1fb636b795b0c7a9cfea794787ef8f6d20b28",
"songs/all_of_me_acoustic.pages/playback-0040.png": "71b20e99fd27f4222435cb1349df375fc93f0749e6c07bc49946493044cfb088",
"songs/all_of_me_acoustic.pages/playback-0041.png": "01e099e1e9bb31eb89c0806ca3885793373243de5cb759b87782aba64548eca0",
"songs/all_of_me_acoustic.pages/playback-0042.png": "a26dc16d90c54856c8897fac364be9d4a5e67616afcd863582e4f36c14e689c2",
"songs/all_of_me_acoustic.pages/playback-0043.png": "1385cc3c40f04f897b69f277dc0f89a3c70fb0f4509722f13ab3f8a08d4a301b",
"songs/all_of_me_acoustic.pages/playback-0044.png": "c12d277f421d3120d318bf7ad14662153a1fa7af6941dd0362da460e967e0312",
"songs/all_of_me_acoustic.pages/playback-0045.png": "95c52e795b0aff090ce88d8f3048ccb1bfedb478a8ea0fc9611843e7d3233b33",
"songs/all_of_me_acoustic.pages/playback-0046.png": "f4fb50ff913ba429633c2e80c5dac60fa16dd3ea9a1c4873794f1e482c6c9c90",
"songs/all_of_me_acoustic.pages/playback-0047.png": "9ce7a7f624a654561fd132628400a16e95665cbc8bc33c437c7d21835de18adf",
"songs/all_of_me_acoustic.pages/playback-0048.png": "2faf0c360563387702a2244b26dba8885d5227c3ded4238d480fb81b607531f5",
"songs/all_of_me_acoustic.pages/playback-0049.png": "4e94b56e4962ad097bf6ad703be517215b11749e234efc86b7bf1e7ed845f0a2",
"songs/all_of_me_acoustic.pages/playback-0050.png": "aeeea727737f35d556abba012444551fde6f1202f99e0959b807d39840384801",
"songs/all_of_me_acoustic.pages/playback-0051.png": "7f918b1847cf774167f62437cc5524b2d7465355e7d3a34bc1778e0c5e01df7f",
"songs/all_of_me_acoustic.pages/playback-0052.png": "5da7caa11f7d223c7be0baf95d049ee226a28236c91a7fe3c95424fe509c5f63",
"songs/all_of_me_acoustic.pages/playback-0053.png": "6dae421ab2d5a85d253f0cc6a750d562a47f2a4c782fc70259c0d0dcdd5f6008",
"songs/all_of_me_acoustic.pages/playback-0054.png": "9a3cd474eaadf4c40b3df43ec805d47d1bc81110d0c56d1e17356d550b731cef",
"songs/all_of_me_acoustic.pages/playback-0055.png": "6489f62576b32c1c7071361fa9d3db372a47927d66f6865ee417bb99c9c04c15",
"songs/all_of_me_acoustic.pages/playback-0056.png": "f7ab14f136adcc844acec06935fdfec2b166972f6c7d6b3993883aaa0ca9e859",
"songs/all_of_me_acoustic.pages/playback-0057.png": "8fcc3723e92877b1005cedba8045a4488e5134e3f1f2185a465b8b6115823f4f",
"songs/all_of_me_acoustic.pages/playback-0058.png": "7d0a9ef281a2e38814506d04932276bcb5041c1c3947dd6aaa874ff066a038fd",
"songs/all_of_me_electric.json": "ae2bf27df59d93436bdcb8ae67821e635014e104e64ec8a4585326bc4bdc087a",
"songs/all_of_me_electric.pages/0001.png": "4080b675aeb49f1b745a18641fc284235b9c3f5e36d07bf258772300f6fed6b8",
"songs/all_of_me_electric.pages/0002.png": "b4d590883d16efdd43d8199324bb825ac23d84f1f5d4fc5c5131a82d504eb756",
"songs/all_of_me_electric.pages/0003.png": "6d16d6d083ddc2c3025c1b379cc145cb0e1de8074e7a51bfcaa817089c51e7a7",
"songs/all_of_me_electric.pages/0004.png": "106a0cd8cd598455a9694d62c3bc905c6e8f038910b40efabafdf43cf2836642",
"songs/all_of_me_electric.pages/0005.png": "5e69575380ed38032dc7ede57d4e815540e15eaa8fad7baeee3d60dd84b7d70b",
"songs/all_of_me_electric.pages/0006.png": "d21342d7e8dd3a63c246ae37dd69567914cb7c3e6c5171de04ed0ffbdc008717",
"songs/all_of_me_electric.pages/0007.png": "c418f413c20c48ce85fcb86ab20eb64d1b9635a45adaac8acfc54403ab2477b9",
"songs/all_of_me_electric.pages/0008.png": "166c390f64d654ac0adba802c45b2c5f3c47bcacf57a4180ddb0f36597f349f9",
"songs/all_of_me_electric.pages/0009.png": "c50e6e4582423176dd2c4e52933eb6263192350f63d16192bd13842d1bd901c8",
"songs/all_of_me_electric.pages/0010.png": "50b939ff62a4430102cd0d851758a326a7d9ee4478afc6471428c3b20692a154",
"songs/all_of_me_electric.pages/0011.png": "2a063242e638507b6978f03043505c441b392ab90943e8b8f39c9a5b6489105c",
"songs/all_of_me_electric.pages/0012.png": "843fd8cd70334df8b327e1316fd3df00efba5f2e7587abc96f14808f25d785ba",
"songs/all_of_me_electric.pages/0013.png": "d1648e1633ad74b790d1b21d3ac85946ea66e048ac8ebebc338dc58eba1453aa",
"songs/all_of_me_electric.pages/0014.png": "5859ff6cd737294f010ecfcf9202924530c037b56e14ffbb29ea7f3fc712a11f",
"songs/all_of_me_electric.pages/0015.png": "b38390c67e0e56a9218f1fd82d386583f8a5fb827255357d6d375ece7cc4e39a",
"songs/all_of_me_electric.pages/0016.png": "ce83a70d0c06b0fee259c1b537b889aa82e74f058a3d46edcbe2956a7cb96aa0",
"songs/all_of_me_electric.pages/0017.png": "97b5da09798a71b8ceae26748da222ea6a90dab14dd59ae4e0eddf55f42d706f",
"songs/all_of_me_electric.pages/0018.png": "124accb8cbb0b8becc086a3e0883052d1d44add715adc46c7fe1e4d1c65d0c62",
"songs/all_of_me_electric.pages/0019.png": "f3886454bbce1128ec0de1de71a2a1dba4dcec41b68fedf815a2c22280c83708",
"songs/all_of_me_electric.pages/0020.png": "6c78af0299bc6cc344800a18f3eeb310ef273f732588dd6d131a9315660c6c8b",
"songs/all_of_me_electric.pages/0021.png": "8d248495a0a6c711d1619475ffafb16ad6f831a6246bb0c32cb2500e69d0ad8b",
"songs/all_of_me_electric.pages/0022.png": "42e5cf25476df3a4ee8030a32d3522401f5de45c539504e544780ebb862bc3c2",
"songs/all_of_me_electric.pages/0023.png": "a7dfa4060c0b5fc26440ed299466db702eeb2cf80cd40c91f83e031f8be53b93",
"songs/all_of_me_electric.pages/playback-0002.png": "403f0cfd04a73ecc7d6277c1ad5d6f0638218386d71a18a960316ad75625de3a",
"songs/all_of_me_electric.pages/playback-0003.png": "0aaa246f780dcdce077fadf99f0bb2febb47eb70462c341545a01c351fa03a18",
"songs/all_of_me_electric.pages/playback-0004.png": "f272b7991e1e2721dea59fdc74836421ef501f7b937b5487ce82985d54feb65e",
"songs/all_of_me_electric.pages/playback-0005.png": "227c94ab1b8f2d28cda0cf2c55710796b1739f48e4e47ae8d88af5b3e7f3fa71",
"songs/all_of_me_electric.pages/playback-0006.png": "196910b9157c0fd142aed1555e294068a726ebf4a12fb077953ab4ffa078cc3b",
"songs/all_of_me_electric.pages/playback-0007.png": "65a2e7466bed99e53a52fa6135926a5d11309e2f5141b14dc1ecde85bb4a2974",
"songs/all_of_me_electric.pages/playback-0008.png": "d28dd164add8c1081408f6310bddad34f60c8fbbf2559d88f8d3024ff089ae18",
"songs/all_of_me_electric.pages/playback-0009.png": "7f9814e8de25ffa797cfcb723bf0146943d9dd0b3c7708e1b8eee34ccdcf65c9",
"songs/all_of_me_electric.pages/playback-0010.png": "0aeb5e7a51504708f1ac1a7f971c8bfd6164745ac334a44cdceb3ddc3cdc62d2",
"songs/all_of_me_electric.pages/playback-0011.png": "d3f67966eb3cc8a5198f8c5832ed9387607e3c657ba730361c06bee9bdeb8862",
"songs/all_of_me_electric.pages/playback-0012.png": "21e1f3c7978796f5b82aa3cf126ece81e409f6c48129681292f5703fb05dc371",
"songs/all_of_me_electric.pages/playback-0013.png": "6cbfc21a3ecdf4980b10636b8374cb918ff017abc5592dc9aef4edf55d27ddbd",
"songs/all_of_me_electric.pages/playback-0014.png": "dd2d3cf5ed13638c91a4b9f92462200494bc78001062f38d8be172d08804e5cf",
"songs/all_of_me_electric.pages/playback-0015.png": "e9dd230dd3410f8ccc6ba55c04155653a02a7cc54ba9903f07bd115894d57019",
"songs/all_of_me_electric.pages/playback-0016.png": "870884db997e94c9bafa6f8a173059dffa55f99f3a38e400e59658a251737b97",
"songs/all_of_me_electric.pages/playback-0017.png": "21f0489f8f948e4131a7c14585169f0d4e9795166a75459eeadc698a49e7c36a",
"songs/all_of_me_electric.pages/playback-0018.png": "e834520df8a74a2f762044d4d2a0bc220124227e348555b33bf73dca11d848a2",
"songs/all_of_me_electric.pages/playback-0019.png": "d046a861e398d0fa4e2eb0bd316f6101455e4c27cb9c73ece1c7a7e8e296032f",
"songs/all_of_me_electric.pages/playback-0020.png": "979701ac47e9cdcd4f21fd63af0dac2e0fede6567c9bb417c4a3133b99df66ee",
"songs/all_of_me_electric.pages/playback-0021.png": "62edac1a5171cd41a1e9eeaf67991ec7574941bb03a74e2bd742ea68840617f8",
"songs/all_of_me_electric.pages/playback-0022.png": "343e6c59f3f3b5718df93d3ef871ca6e041a0a674d719df122b6ab298dcd96ba",
"songs/all_of_me_electric.pages/playback-0023.png": "14735b44d4a7b65b957c1d733e7082c639a0e4acc9caffffde1b7bddf236eee7",
"songs/all_of_me_electric.pages/playback-0024.png": "cc21f078595a1fd138f00153b30cebcccecd0acc8c8b4a4a17e7908ad854e1a6",
"songs/all_of_me_electric.pages/playback-0025.png": "ce3df5c546abada13bf1cc0b409d30239df2bc9eff0f7d04857c75b4734bc642",
"songs/all_of_me_electric.pages/playback-0026.png": "367021a47fac7e2dc54e3288399b708ae46bd21a6055e8593eead39d1b195d58",
"songs/all_of_me_electric.pages/playback-0027.png": "8544e3d66736b42b4b4c6f4433b00945f14aabdf2fa02159e666e5005f66206e",
"songs/all_of_me_electric.pages/playback-0028.png": "96d88ee25fe1e2543fb0599a5e6f18f6fa20430146a3cbe2ec1cb4e164776943",
"songs/all_of_me_electric.pages/playback-0029.png": "689aac0d1696ce3940d05bceadad057c485bd36ffe6615aa679ee974c36b2cf1",
"songs/all_of_me_electric.pages/playback-0030.png": "c9961fb33a22d8cfe0ac23b708b491c8fe066c28565f946634aa35ecc50d2164",
"songs/all_of_me_electric.pages/playback-0031.png": "eb03bc4ff5d5037c5e068323060610f268678a4c8083d3f4f727c7171cc6b1cb",
"songs/all_of_me_electric.pages/playback-0032.png": "6ee11cbeb454dd51199b702178f54c8c17bb626542776419818477c77bb5c6e6",
"songs/all_of_me_electric.pages/playback-0033.png": "8b0bc5cd261d542f6aad5ba56d23aabba32892739b32e09e84f0c147beea7708",
"songs/all_of_me_electric.pages/playback-0034.png": "f413fb00b1231b1783a0578063212217043adc73a270c23cad8c8913bfd70e98",
"songs/all_of_me_electric.pages/playback-0035.png": "88f772174ce53af5d84ac4d804d827e4c3096ff64126b0ea2eb206589d75fe46",
"songs/all_of_me_electric.pages/playback-0036.png": "f0e09108d57afdc64791164cb8df197ff1794e5df3bd803325b1ab4ec904d761",
"songs/all_of_me_electric.pages/playback-0037.png": "b1a6b868a18f4a5b18dfb8b11d38ed5e5505c3a1736cf4d71172c6f75d9e6c22",
"songs/all_of_me_electric.pages/playback-0038.png": "aaef3ac9fc6d52703bb4bbfe588c51f630e6cef75a85fa9cbd3197c5c252bb23",
"songs/all_of_me_electric.pages/playback-0039.png": "43606ff740a8127560d60a384b9ca910a294cb96de13f3da2492a646097abe82",
"songs/all_of_me_electric.pages/playback-0040.png": "8f7809d7a0fcad64e9a7c5ab644b7de771460d7d48b554ef603d0c93a98a0124",
"songs/all_of_me_electric.pages/playback-0041.png": "7c0befdd861d8a98d170c04fa813a0c091568bf74bf94fcc14024ce7618d016f",
"songs/all_of_me_electric.pages/playback-0042.png": "349e59923c00ccd2675f61763dcb2bc81d9debe3efac409e0b25d8bcd1e3bd42",
"songs/all_of_me_electric.pages/playback-0043.png": "90c5d4f9149e380fcfcfdb18882381f535c8f619b475c480210dffe3570a3d8f",
"songs/all_of_me_electric.pages/playback-0044.png": "3548e497fe84e7a6f3344cdd5bfb7b4d44e89b6f2cc6778a8bf891fdb0d95ae3",
"songs/all_of_me_electric.pages/playback-0045.png": "1f21274695106a635e92551f7d12b739a82f1a5a17db8d911e60a00fca170e10",
"songs/all_of_me_vocal.json": "04c2712621766ee10529135463c0ceeffdd2def707134c81fb450e30a2d6cd67",
"songs/all_of_me_vocal.pages/0001.png": "5ff59ef10db0937c0dc132effc4f1ab761c6a4cdd9898aed1cbfc79c3ee126ad",
"songs/all_of_me_vocal.pages/0002.png": "8c737499c4e537b779160b86bf779de9b0a6771ece8f594af64ccd66e896f0d6",
"songs/all_of_me_vocal.pages/0003.png": "1ae152662e101117056704e7ccc92e7fadf546e4b0b68b11a463b8a778a2449e",
"songs/all_of_me_vocal.pages/0004.png": "d921899d08554455d72c71709d1110d808c5227de5cfc0c9aff68a168c7d4ecb",
"songs/all_of_me_vocal.pages/0005.png": "4c9fb6f70a887dceb7aca582122c7ba3d06a38431a37247b422e11a81f917e5e",
"songs/all_of_me_vocal.pages/0006.png": "a2792f1dc70d11d7f1b26f4c51686c8c6739844021e237c0d30f3604a2bc6306",
"songs/all_of_me_vocal.pages/0007.png": "ecdad2ac3c0a38bb0a553986b68fd64b6bce4dae7e2e654c5a152bc83abc61b9",
"songs/all_of_me_vocal.pages/0008.png": "0e619566374434cae278bd66344d94c80507e7477f678172da1f1cdf24bb65b9",
"songs/all_of_me_vocal.pages/0009.png": "8ef33bc47271fe389b3cc1c03be11e8689e8f5d1e3638a8af127adecbe90cde2",
"songs/all_of_me_vocal.pages/0010.png": "5f40610e4d3b143d3260b574c28c633b3a18e6e298fb3afe8adac95878bde3f7",
"songs/all_of_me_vocal.pages/0011.png": "32c857d8cdac61f34eb97031e27dce2f3b7d85f354e002c76c6947c07a49fb48",
"songs/all_of_me_vocal.pages/0012.png": "f6674afe2c6f4ead074491509ff12fc572656a73d894b170a7ef78ea0f83adce",
"songs/all_of_me_vocal.pages/0013.png": "886cee78fa1a4f5868a6adba5205d0843a6b9de0af42ea80e047c514b6ba0bc4",
"songs/all_of_me_vocal.pages/0014.png": "0e7d208b5da11cf6fd25b499ee138d865e63e85e2d9392ea9363623145b5677b",
"songs/all_of_me_vocal.pages/0015.png": "ba792e980325d694638ff90c7e9b87713de6b313c8ca2667e2889cd278581c42",
"songs/all_of_me_vocal.pages/playback-0002.png": "ad7fc6426e39e204109e3122bfa142d203abe60ba76a59b4df507e61745d923a",
"songs/all_of_me_vocal.pages/playback-0003.png": "4980352dee0600cad30f54ffba81577f4285248c7987df23aaa84f28544d02ed",
"songs/all_of_me_vocal.pages/playback-0004.png": "ad23b22d0fb931f098b88124fb6e48d2a1b03437f12944d8dbfbab9e5a6639e1",
"songs/all_of_me_vocal.pages/playback-0005.png": "065262ff6b758419107fd41f7b3956d996c325d6552a72f62281288c57ed29c1",
"songs/all_of_me_vocal.pages/playback-0006.png": "bd6cff39e52a41aab31473740a77357c47b2bb2b3dff42cfa794d3fe7888d7c1",
"songs/all_of_me_vocal.pages/playback-0007.png": "7cf0ebec216da49ab7385514a10e466656770d6668975840c8967f132414d2ce",
"songs/all_of_me_vocal.pages/playback-0008.png": "b4d59c19612d34d766cf2d76db71c6d6238d9afd6ce88f51dfebd117e6b1c5e1",
"songs/all_of_me_vocal.pages/playback-0009.png": "3d30c0180874fee2163821c4ef160cc5e134e5c5069f6e77ba542e40f5174bde",
"songs/all_of_me_vocal.pages/playback-0010.png": "5464485dc6c19973ac39dd3e573b9d7884cae22a6ef632f3c31e0e863635c7d6",
"songs/all_of_me_vocal.pages/playback-0011.png": "761aa1f8a8096046dabef1c1a30875205aec3309030cd60ae27938f51247f154",
"songs/all_of_me_vocal.pages/playback-0012.png": "0285ff0feffa0a61ec1a26b3eea54ca9156cba357847c74b1cfeac2084d454f4",
"songs/all_of_me_vocal.pages/playback-0013.png": "6177b132b398724081c814a65006ec205df52ea90403ed0c543e6ac9bdfdf2e8",
"songs/all_of_me_vocal.pages/playback-0014.png": "5fb997b60b54f808490b9fa0c6fedc396540f3b407c33b4562b54290daca4afe",
"songs/all_of_me_vocal.pages/playback-0015.png": "2a4a6345c63a9e6548519a3303d369bff76896eb1c4bfb40da42dc29fe08f710",
"songs/all_of_me_vocal.pages/playback-0016.png": "2ce1db668951fa36a075f119a423b1f50a1c4f346a29b1077cd55ecb31a5fa7b",
"songs/all_of_me_vocal.pages/playback-0017.png": "9f2677349e9d54f8de873e12399fb1f30a24d18e5f06c990aea2190cc41737e3",
"songs/all_of_me_vocal.pages/playback-0018.png": "25e7ab38426b3ce2c970c0c474aac7758f0f8c6a816fd0472a1a98ae4e37df9f",
"songs/all_of_me_vocal.pages/playback-0019.png": "f580eb1801c2ffad826c48a5a3c0b35c7513a8e4949723bba3f8f4522d03534e",
"songs/all_of_me_vocal.pages/playback-0020.png": "e6f23793d569cab0069560e88b1bc38e8fe44b1a11843d3a1a7639a33a6ab2fd",
"songs/all_of_me_vocal.pages/playback-0021.png": "a3cd07885f5ec333ba7ab3bf61807cb66263b7a4da4f1cc53e36717388a0cc13",
"songs/all_of_me_vocal.pages/playback-0022.png": "82529db7c06aa1ebe3bf5608287d2f4a6822337a5e1261d4687a6a9665478efc",
"songs/all_of_me_vocal.pages/playback-0023.png": "0b2c34c2524e5d437187d4bbf5cda395c55149081cc1478405a338d29af02ee3",
"songs/all_of_me_vocal.pages/playback-0024.png": "08d783cfb2d4bc09e83f3647a86455394f044dcce723ab9c5fa55063b222e844",
"songs/all_of_me_vocal.pages/playback-0025.png": "9c65d79d14f04bf05bee9de69c6c37a871e50aab635de797a91eb62fd81aac31",
"songs/all_of_me_vocal.pages/playback-0026.png": "79e87e2185d99c32aa753ac54068fabb698f81b16d6017ee7285f3b7c4d20054",
"songs/all_of_me_vocal.pages/playback-0027.png": "015cd105d6e5d2130393ced1268413b4929ff77231c30501b3a2748be6783b45",
"songs/all_of_me_vocal.pages/playback-0028.png": "7f3f16ced98698ef1034101976c05a1fb33b7c513057066b160b42eefacdbe64",
"songs/all_of_me_vocal.pages/playback-0029.png": "dfe0f2ea02945997653b0fac1b6d1355cd1c9a2cb5307f38ee59d51a54d4ec53",
"songs/all_of_me_vocal.pages/playback-0030.png": "98e2a6862eff212591ffc51d55c48ab65267e670bdb56d6c7ca6fb00ad750086",
"songs/autumn_leaves_acoustic.json": "e07a7e4fec89c811e6c26c1bba64af690743662074faa6b8f0fc81288097b53f",
"songs/autumn_leaves_acoustic.pages/0001.png": "54cbffc07ff6a31d8c340a4acaa14e6818b50b08d60041d7aa409fed01c2864c",
"songs/autumn_leaves_acoustic.pages/0002.png": "563ffa15c52a986603a17596e3ae9177bfc5222910a5c5bf31cbf61cdd1845fe",
"songs/autumn_leaves_acoustic.pages/0003.png": "fad7549f767ab0b261e58266ef9d6f227f5fe8d90f5c1ebcb8ecf1b0fee790a2",
"songs/autumn_leaves_acoustic.pages/0004.png": "fb5b442534e05f2fa9729965d0a05acc9c2d2878b9af297b6d646d7f625dd1e2",
"songs/autumn_leaves_acoustic.pages/0005.png": "a32a12e59624f65cf30685c5f4439514575d54fff42243506278b68b4961199c",
"songs/autumn_leaves_acoustic.pages/0006.png": "749df9fa3bf3bed1033b7a9950755f64e19e9793da91f35fa95e44da85d7dd4d",
"songs/autumn_leaves_acoustic.pages/0007.png": "e90ab72d11b381e70b11c987532ab25d84518d272fb7b03517d25a1f952aba6a",
"songs/autumn_leaves_acoustic.pages/0008.png": "846f529b487a4df90f8041d0c893225e8b3cf71feb1bd8ee903fd3a31126b8ef",
"songs/autumn_leaves_acoustic.pages/0009.png": "e8ac60d749ee6e1be2bd40457be800927eb8a321d11b2b98e663ef3835d2e8bd",
"songs/autumn_leaves_acoustic.pages/0010.png": "694c524e7cfc5b23e0fd8a301c230f7871bb783ad5243b7d45f34101a2ea22ae",
"songs/autumn_leaves_acoustic.pages/0011.png": "25e7b830b5fe8b9c9aaab2d31ab713a1b8da720f2ed116c547162ad77b99c10c",
"songs/autumn_leaves_acoustic.pages/0012.png": "afb17b0fae1be9504395a497776033736d7eccb8b040061333bdfe456035acb5",
"songs/autumn_leaves_acoustic.pages/0013.png": "76bd3e7c8a6c3a95a98f8c96e521509bc3ff4563fd5446032939e7c2c577f21d",
"songs/autumn_leaves_acoustic.pages/0014.png": "9701a0f3d5f5a3f32fb18ff1c11c5cc525961a9a9416c98b83818ff4607afb37",
"songs/autumn_leaves_acoustic.pages/0015.png": "235584fcf484de70f3beb463d1eb3783ed20d6c296eacd1fb8a5b9b3127eccab",
"songs/autumn_leaves_acoustic.pages/0016.png": "fe7ce9c311c2f57e9b6cb10fb5ca01f7f103d0d0f29e4da806e96b6cc6e005a9",
"songs/autumn_leaves_acoustic.pages/0017.png": "639450b174f68900a71cbbd3ab8a66b621f239e07922673e9482e49f30a892a0",
"songs/autumn_leaves_acoustic.pages/0018.png": "c15b4d2cfa76bebc9866dd022527999b0e7a09726738a7da6ba2285c7c4d9420",
"songs/autumn_leaves_acoustic.pages/0019.png": "84f637a60432cc35981a895899993b9b1b9f35a6ea6d211bb4aa95ccaeb42d8c",
"songs/autumn_leaves_acoustic.pages/0020.png": "e20b7a45b003e68b0f890b263f06adbcda947c3ecada541c2f0feb9dc9424741",
"songs/autumn_leaves_acoustic.pages/0021.png": "1651902b453243d5bd7fbc203417d232398063d2161eae37dfaba9327a3d35ad",
"songs/autumn_leaves_acoustic.pages/0022.png": "3051d9af506ddc661b259f2ca5c9f2d80076b1e9d6e303f38970091958f48bf0",
"songs/autumn_leaves_acoustic.pages/0023.png": "7c554926f10ee5090bde0da5f182f965cf8650889f8a3d6227334006ba65ec8c",
"songs/autumn_leaves_acoustic.pages/0024.png": "ead80368cefbb664988df29571cc3ac68a4ec599d70f0c53d628715e9795fc1b",
"songs/autumn_leaves_acoustic.pages/0025.png": "953fabb18457d14a5797ad5a6fd5ce0f3af099bb3877f08fd2813fdeca7e2481",
"songs/autumn_leaves_acoustic.pages/0026.png": "13da788d7bcce194552854ebdbb1601d5322bed6ac0bbae09e3cc07c34610379",
"songs/autumn_leaves_acoustic.pages/0027.png": "bbc81588269392772e6ae2671289716e1f1b51cfe8ebb790e887e866e465a7a7",
"songs/autumn_leaves_acoustic.pages/0028.png": "109adbfb7abf9059d198ce6abe1612457f6f0674636219ab33ba109e89fb8ef5",
"songs/autumn_leaves_acoustic.pages/0029.png": "1052f1b8e1e7746fc846c6893a02e587b0b204f32d8307800d7fd74ac06383ca",
"songs/autumn_leaves_acoustic.pages/playback-0002.png": "e82e130512e34e2801d39a730d4acac3ca689e0736ab100770d49404a598d034",
"songs/autumn_leaves_acoustic.pages/playback-0003.png": "5ba697f7f059b627dd4467589c4f4891413cbf7ad85932800a7fc00c7c70255a",
"songs/autumn_leaves_acoustic.pages/playback-0004.png": "f1bb58fce2cdcffa5523ac60099b6e52c356c6696fc1a4ff60a5cccd2dd3ee1e",
"songs/autumn_leaves_acoustic.pages/playback-0005.png": "15645d74bb6ab75901e87485dbcfc2c797e8a79b03973c1e1708889be2fc68c8",
"songs/autumn_leaves_acoustic.pages/playback-0006.png": "724eaab2251f9b799a439fb0fab1cb200ca471ee8a7e13718167e0de7481e20c",
"songs/autumn_leaves_acoustic.pages/playback-0007.png": "b1043e1876883e3d380cf8e3a59b2f6850d48054e9d0e570ca1eb988f3b78cf1",
"songs/autumn_leaves_acoustic.pages/playback-0008.png": "e5529398605d81e4265239afc5b6117bf0e89743fa2662d9edc4c8a1a71dba3b",
"songs/autumn_leaves_acoustic.pages/playback-0009.png": "690e52975aee8c6e21231e3d2e8be84fcb15fd42134bbb69c8bdccc3e8c8be7a",
"songs/autumn_leaves_acoustic.pages/playback-0010.png": "fb950d14561f570f1f645903cd4bce289bac97d182a237b1d26d3ca4c1e40357",
"songs/autumn_leaves_acoustic.pages/playback-0011.png": "3c6d7cb9269f25fd89feaac66eb14c4e1c5f40b1c1e5b4a11cd9993e90d70180",
"songs/autumn_leaves_acoustic.pages/playback-0012.png": "b317e5306a4f8ab59bfd21688640aa02df31382dcadbaaeac016e8682d4a95fd",
"songs/autumn_leaves_acoustic.pages/playback-0013.png": "b0949d15a05cb2458fd2fa5f6f842855591ddcea8caa260ce3006d849a7b3498",
"songs/autumn_leaves_acoustic.pages/playback-0014.png": "676df949c9074b9f096f1ec8535ff78a8852020a23ba50055bf95e4a03515771",
"songs/autumn_leaves_acoustic.pages/playback-0015.png": "1da00065ad926e8e46ff475f1db3a21d1beb1b7d021fe1e899e1ceb330dd6a6e",
"songs/autumn_leaves_acoustic.pages/playback-0016.png": "62504e382dee30dbb52e999c70bb4cd7619412cdd4304deb619527f5e81e03db",
"songs/autumn_leaves_acoustic.pages/playback-0017.png": "4f30f876ad7bec3f1a7962838b9431b640a1cd663421068e8d31400c4123b1c5",
"songs/autumn_leaves_acoustic.pages/playback-0018.png": "af9ddd4d84fddf1356ae21ca5f54f74011bbc7860b6ca34094daba6f0374ae8f",
"songs/autumn_leaves_acoustic.pages/playback-0019.png": "b46f18b2e4dcd8272f6b97a4599787e16a94afdd78adcb3c697cbc415573d861",
"songs/autumn_leaves_acoustic.pages/playback-0020.png": "6c84621d515f3fa2b6b5ddedb4c7d7a28e1f7916ab86241a27aed3e5e0036c97",
"songs/autumn_leaves_acoustic.pages/playback-0021.png": "9a95f5db7d7350cd5dd1b81408c65395826329eb0f2e95d0f12d93f281e7cf69",
"songs/autumn_leaves_acoustic.pages/playback-0022.png": "9c41ee3793c44018873ffbc0847e001a2d2a7273ae487b1d8f47a7ec10b1c51e",
"songs/autumn_leaves_acoustic.pages/playback-0023.png": "213ee4b1ed54411d16c880907b0d4d6c04ba9f97bed9eec4e4c5a09067040987",
"songs/autumn_leaves_acoustic.pages/playback-0024.png": "b37549829a19c82c525d4b2710b6ad0db21f7ca9735b71817e6e06de826796c9",
"songs/autumn_leaves_acoustic.pages/playback-0025.png": "950f311dad81dcaff3b6d73128c2869ab6d7277b7bac4358599d9bad3fe07147",
"songs/autumn_leaves_acoustic.pages/playback-0026.png": "6495623d373cf5d464c01e8a3c7f6e95984af75fc8ba6370fe72bba2a9c5a1d2",
"songs/autumn_leaves_acoustic.pages/playback-0027.png": "36e1a9c2df257d706af5f319c4616dfc9bcd6aab587f97bedcb4a1e8594a524d",
"songs/autumn_leaves_acoustic.pages/playback-0028.png": "e732e810da00f84bf74fe2f26d57b2fae01ea4691c39bf17cfc89b7707672716",
"songs/autumn_leaves_acoustic.pages/playback-0029.png": "01da01ecae8bcdaad33240fe686b2f44604a40ed5979cd5bb0aa4481035c9f11",
"songs/autumn_leaves_acoustic.pages/playback-0030.png": "52d1acf4d3cc5bc531ca879e9d47281d72dafb8079dca349bd12965528ecba52",
"songs/autumn_leaves_acoustic.pages/playback-0031.png": "e0c220f7f8522679bf628b03ce472b03340b8951f4be5010952822fcad8f6bd2",
"songs/autumn_leaves_acoustic.pages/playback-0032.png": "42801ea8c17bc1f49689a6ca7379928dfb1e569ee7354a014451ded48112bc5c",
"songs/autumn_leaves_acoustic.pages/playback-0033.png": "e447393a28aad3360e02dd4fdd6886eff73a46503f94511056938f68e629e13e",
"songs/autumn_leaves_acoustic.pages/playback-0034.png": "0b490c24ccba2e1d43649e64d21e48b1dea2f34f91d2e459d850bb02a4fd5e17",
"songs/autumn_leaves_acoustic.pages/playback-0035.png": "079872d5fb0bde62bc41909ca74246ed9804c6d46e8bef78185fffd6f41fd33a",
"songs/autumn_leaves_acoustic.pages/playback-0036.png": "6b90cb2c9cf07e7e5896cb57961cba6de2c5cb226772e0ad67ca75caab34dc3d",
"songs/autumn_leaves_acoustic.pages/playback-0037.png": "d5d2d73bfac0ad110ac55c54e87bd63a26799d14af76fe20378100e72b715782",
"songs/autumn_leaves_acoustic.pages/playback-0038.png": "7ee879807b0e1371326ea4cfd38ba59218c649397ef1448589f0a4b0557b9b85",
"songs/autumn_leaves_acoustic.pages/playback-0039.png": "9566293d49f978823df090545d7239172ce836b54d0a184c2f50672509f5095a",
"songs/autumn_leaves_acoustic.pages/playback-0040.png": "f4a68c14292b2c8cf1b870f0f5ea74603439a20317c931435b94bf4d69b6c7ba",
"songs/autumn_leaves_acoustic.pages/playback-0041.png": "d899bd39cc713bbda7c521537d81c775b609e02df199d4b740eace1a948cb27e",
"songs/autumn_leaves_acoustic.pages/playback-0042.png": "e6f07ad76a91f5a0a1c563a238e5f859fd0d8799d18e429ebc6967c1c391ae0e",
"songs/autumn_leaves_acoustic.pages/playback-0043.png": "9b0cd3c1bce012b04afe478cbeba630643de02d695324c42f2062c515efb7321",
"songs/autumn_leaves_acoustic.pages/playback-0044.png": "1eca5856dbd1f7c58297b9e47766675efa44fabbbbf5668d07dede8d47c2bdea",
"songs/autumn_leaves_acoustic.pages/playback-0045.png": "d9eaf533b05d3a889bba6005f1cef1c1b62466db18aa7bac819f5ba2b25d27e7",
"songs/autumn_leaves_acoustic.pages/playback-0046.png": "8019f806b1782995ad79a2f06463e0241c3442195ba9cbc3f6a0c2719c3afa85",
"songs/autumn_leaves_acoustic.pages/playback-0047.png": "b0384920846c4089363ef8cfa4a5ce70fc2562788592a06138282b4c019da225",
"songs/autumn_leaves_acoustic.pages/playback-0048.png": "b7a7489c6ef5744c5daa995600896dd2694eeab383f878a02dc7057e530d657e",
"songs/autumn_leaves_acoustic.pages/playback-0049.png": "c76ff27923d2908a30e1eea6d880064de7e7c941814608452c3b8dd594daf850",
"songs/autumn_leaves_acoustic.pages/playback-0050.png": "ff9d2e94684c7b0342f6b9833c8e7e3c5611be9a436e8fd403d809c8540f8ed5",
"songs/autumn_leaves_acoustic.pages/playback-0051.png": "f376a335fe15961cee9781f8ea53307b3f6578a06b194f3304e4704278e5defb",
"songs/autumn_leaves_acoustic.pages/playback-0052.png": "8be1c27434d95f231ddad913981f140a3df9beb4914ca6e4d1e4a5dc42a65798",
"songs/autumn_leaves_acoustic.pages/playback-0053.png": "7261e3d2465bf7d4de7b7710aff229d64b61cf3861870cc6aeda931d88d602b3",
"songs/autumn_leaves_acoustic.pages/playback-0054.png": "51375ba98654c3ddf198dd4da24c72b688b37f6c3670fd0eb77ffa5fdabb3965",
"songs/autumn_leaves_acoustic.pages/playback-0055.png": "c2e8d36d522a39131eff7af3cfabc8139baa90ca55d1e523aa6cb2590be933c6",
"songs/autumn_leaves_acoustic.pages/playback-0056.png": "2d4c1e0611eb4c4d2e653e25f4719f4da8ae93b4d481e22bc401f615cd04093e",
"songs/autumn_leaves_acoustic.pages/playback-0057.png": "d15d0441b80e087fc3d8f01dd7086a6fcad5ec6c66df3ee410920ddf8e8172fc",
"songs/autumn_leaves_electric.json": "90250a7776c295c4886c1cad6aaa0ee5f42430e9309289fdc35b8a99ec0e1de3",
"songs/autumn_leaves_electric.pages/0001.png": "6a10d99239f2a2f7cb86f10a371410191e8eab0d7335ac96f382efb7ebc8be73",
"songs/autumn_leaves_electric.pages/0002.png": "6ab880d8341c5ee92fece84d0756f942bd12e4f80cef6648f3ed779d386a93b5",
"songs/autumn_leaves_electric.pages/0003.png": "26e73ccda608f5baebec6e9c1beceb5586b13b5d138f0d29d4c13f58a6924715",
"songs/autumn_leaves_electric.pages/0004.png": "d7d47739c2c32a676198d091d546e027e218530dbe76147a71ad7156dad96f8c",
"songs/autumn_leaves_electric.pages/0005.png": "3c190110e3b59e42185dedcd4351e36f90d6e79788732f91b55fc92e059053ab",
"songs/autumn_leaves_electric.pages/0006.png": "39ce5045c8bb217a3b8820d4a1af14896faf313ca91ebe63ff848eba6c76e0fc",
"songs/autumn_leaves_electric.pages/0007.png": "1f5f36692863b4ccc4f08cf3a8b9c2c8edbd9f5b72d8ee76aae78dee8fb5220f",
"songs/autumn_leaves_electric.pages/0008.png": "52e12ff6e4983451ad7e59c45f3c5cd049171e00f2c19ce40e10de994764cbb7",
"songs/autumn_leaves_electric.pages/0009.png": "eb18d7f17fd0a101ae02df700409f7064cdc1ed585f315dcc25220490a3a3ca4",
"songs/autumn_leaves_electric.pages/0010.png": "8b923a580500ec4dddfba53ac57029806acc34f4bc51045296d46ba9db9fe0b8",
"songs/autumn_leaves_electric.pages/0011.png": "ceafe100023db75a8d34ea08e8c66155ba76aa12e8a24c9b83294d2a01779ca9",
"songs/autumn_leaves_electric.pages/0012.png": "813635f070252ce2cfa76de28fdb7badca2534783edfd7d791983d8b22d05b20",
"songs/autumn_leaves_electric.pages/0013.png": "e828faeb75fa104b6e15991e0489db7a43652bbde47ebcf818348a54614d5452",
"songs/autumn_leaves_electric.pages/0014.png": "6bcff78f5fcd2b214795e1463cda7f817785f2cd80c9727147ea12fe169760cf",
"songs/autumn_leaves_electric.pages/0015.png": "19bde11ca45dd5927797ee23a8cdcbfc891e7ff29bc9a01c2ca945b84e07e5e8",
"songs/autumn_leaves_electric.pages/0016.png": "071a61fa83944bea0b04aa221b19d124876c2a5f1b1e8347b8edaed4ed1aae92",
"songs/autumn_leaves_electric.pages/0017.png": "1d1ecbdf8e123aa2d003e352e85070eaa8b20979dbe25ea04be61f9fb8efccd0",
"songs/autumn_leaves_electric.pages/0018.png": "d551633f1c99dbe7b74e2fb2c2c7ef490eceee0d812cf697702de2077c2b74b7",
"songs/autumn_leaves_electric.pages/0019.png": "71672fc8613539914aa4069a4668751363e0be3d2e9822cf078755fa6b9116f3",
"songs/autumn_leaves_electric.pages/0020.png": "86747ada8e9ff0b1c05824d1a1ec9328f01cf33a65c3ae13c64a1e075c0a52ba",
"songs/autumn_leaves_electric.pages/0021.png": "c981fe628c1621c6f5add8635e33aaacae112fe954b2b43f15b3f02604c6c9e9",
"songs/autumn_leaves_electric.pages/playback-0002.png": "53f34213f307f1c7ab471c3c28db34a28e551936989b1a450282a325b533b71d",
"songs/autumn_leaves_electric.pages/playback-0003.png": "33f686464316f82a30d2b71bbafb6f8d2a74c44461f9b6b2c4754632671c4381",
"songs/autumn_leaves_electric.pages/playback-0004.png": "d99dcc2f8f15f120c2bf3cc6eba91b794aa57cd0078961d930ffc8dae843c791",
"songs/autumn_leaves_electric.pages/playback-0005.png": "568f242912b71a2a0e6b438fabaeaf043a62087d1b3018d61838093e6812c392",
"songs/autumn_leaves_electric.pages/playback-0006.png": "dc5b754b3d11d136a89c82b3160b3cb631572065505b2770f529c312c8beb0cc",
"songs/autumn_leaves_electric.pages/playback-0007.png": "13d877cc54210a860f0e08006c5e386a6a3e5c339e2d334df11ad37afd8b8fec",
"songs/autumn_leaves_electric.pages/playback-0008.png": "90c1df01c76f8c919cfd7276f80a899259851e090975ea1d1cabdcc613d02d72",
"songs/autumn_leaves_electric.pages/playback-0009.png": "dc2be361fa7c0c2c56fe02c24a97bcb628a1eff960e8b1db78739679f562759e",
"songs/autumn_leaves_electric.pages/playback-0010.png": "07d5c2b2ae31ebd88d4fb4708caefac36aca5d436048c1ffad55feffa6d87925",
"songs/autumn_leaves_electric.pages/playback-0011.png": "8d22eb7cc86906c36967434e31811a4542b1e44f6590eabff23f9a940916d207",
"songs/autumn_leaves_electric.pages/playback-0012.png": "c964863cbd956968210fc939b75ef6681d6f797f50860b75d8b7d0736ca12305",
"songs/autumn_leaves_electric.pages/playback-0013.png": "7d9c22406e24aff928e56cbebbb4749d1e8880542975fd53447afaca19b44584",
"songs/autumn_leaves_electric.pages/playback-0014.png": "2fce4a9c3624e90ebf964965a11d37e951db7e18824a2268c487abe2dd414168",
"songs/autumn_leaves_electric.pages/playback-0015.png": "edda9e50c852a5da934c21cd2d8a70a19e8c54b9c7020feb0d542ed0b257c2b7",
"songs/autumn_leaves_electric.pages/playback-0016.png": "50cba6043f888330d3a01ecd695de8f3ed7f3e51ae94da54b3faadb1088e74de",
"songs/autumn_leaves_electric.pages/playback-0017.png": "7c8bcaccb8b7879665b50a1185a90cc3ee31e6bcd21ecc2fed2dcd723d31e114",
"songs/autumn_leaves_electric.pages/playback-0018.png": "084a522a8e146a7fe533b3498eb842a586cf743c6a034b79f15b067a6ce52a4e",
"songs/autumn_leaves_electric.pages/playback-0019.png": "9f47a55406500ef63d7936065fd53685cef4d72b7b2190a14e6daf28e4d8bbbd",
"songs/autumn_leaves_electric.pages/playback-0020.png": "95a7d3e90b7a7cf435ce87c1d83366822f38f53857d993cb9c242afce3f1ac2a",
"songs/autumn_leaves_electric.pages/playback-0021.png": "f983e3bccc183efa8fbbe9f83a7629b056820d15129bc84108f02934a4b52ae3",
"songs/autumn_leaves_electric.pages/playback-0022.png": "614a51979fa8c9229503018645533118f05f496ffee4be4003e18a2281015844",
"songs/autumn_leaves_electric.pages/playback-0023.png": "223fcf58efd3e678fd0e5a761a2f504eea569a1cb4f6b27f85baa8d425bdb499",
"songs/autumn_leaves_electric.pages/playback-0024.png": "8c3ef22114aa00a42f603daac996f923d3d4aef9ee1cfdd2daa437a8760a9c85",
"songs/autumn_leaves_electric.pages/playback-0025.png": "ba7d51c2f2eeeefa52099d2401d695ccdf3091a49599c206eaa1daa39c68eccf",
"songs/autumn_leaves_electric.pages/playback-0026.png": "9c09778168a6b0b44d3beedb49c915a9e126fe47e8ec0afd4e72061ebadae533",
"songs/autumn_leaves_electric.pages/playback-0027.png": "53f851a9583415aa36f60aa4655f04d92e38cc3a679d9205bd93fbc971798e41",
"songs/autumn_leaves_electric.pages/playback-0028.png": "591e475f21cf481cb35278ee065cd808fa141162320d54c1ad589d8ff692143e",
"songs/autumn_leaves_electric.pages/playback-0029.png": "01fe5d38ca9867a442696e3332fb1a5c11ffadcd1e1182ed6164f6caae1c7950",
"songs/autumn_leaves_electric.pages/playback-0030.png": "6cc22a97e81be03b6eef0ef11b0cf8d088336eb19da26a8bb4e366c3615145db",
"songs/autumn_leaves_electric.pages/playback-0031.png": "93a7835d071c07c17d9b3fc50e1e2ff3b19d0b3087f21ef19c18bc622519ded6",
"songs/autumn_leaves_electric.pages/playback-0032.png": "ffdd2864d336f12375e963cb0fd4647992b790d92899f809a17f0aa2941b9324",
"songs/autumn_leaves_electric.pages/playback-0033.png": "8c2097ce48fb79c218aeeda3513025d3c6c3488aaf4682c672cf703f05366b74",
"songs/autumn_leaves_electric.pages/playback-0034.png": "07192aa9420386cb8165b1f43be384b83a7b588b383e57d3905b235acf52d77b",
"songs/autumn_leaves_electric.pages/playback-0035.png": "786e44c54bdb27b095dff2f903df5319e794355f986a2ed86882de78e299b589",
"songs/autumn_leaves_electric.pages/playback-0036.png": "f4d8b82bda3c599dd803b66a5ef1d7ef77f57d5757dafcda168caba6fb8a0399",
"songs/autumn_leaves_electric.pages/playback-0037.png": "d3e999968b05cb703fba44bcb1f3b8cc1b0934ca7513f9e952db41a27d9dbccd",
"songs/autumn_leaves_electric.pages/playback-0038.png": "43773db6f940cdd5457053e9d1cebb1f715d7a9d069fef8f29dd280bc664383f",
"songs/autumn_leaves_electric.pages/playback-0039.png": "da683c3e3d6198a49464d6e9463f9bed2ad9595c6c69ac09adaeeeeddb626003",
"songs/autumn_leaves_electric.pages/playback-0040.png": "1e1fe40450357ed40ab4a688906e62c69306dc706ec53e4c23de94d7228dffc3",
"songs/autumn_leaves_electric.pages/playback-0041.png": "f7c3ab577b12be529b9c73b44521852db930681e6dba1410aae569e526fc4a29",
"songs/autumn_leaves_electric.pages/playback-0042.png": "a827c32cf60d5bafb3b042d36f14883bee389e677091bfc5033771356c164254",
"songs/autumn_leaves_vocal.json": "412981cfd4ea208bbda8de8bbfc2d751164c379340bc02a61233692ef19f1032",
"songs/autumn_leaves_vocal.pages/0001.png": "4b9402563b8ae88a34f25482d5db3d5279643bed60731c7baac69a030b3ce2d9",
"songs/autumn_leaves_vocal.pages/0002.png": "b961fb05e5539ce4335b561df9fc9558b81574872a35956da0beb192a8db258e",
"songs/autumn_leaves_vocal.pages/0003.png": "a3e5423f083d8159e52dd4f1fa1532b982a4290dc3db75921a120fa3befb5c7d",
"songs/autumn_leaves_vocal.pages/0004.png": "9aecc0d1c84bdebdf214c99469ef2fbc7a8e625471b8b06e26c3267dea1dfc42",
"songs/autumn_leaves_vocal.pages/0005.png": "80f3956a196ef56b01647c7daa0d58989c12f04b2ddd8c166cedaf846c1d0f18",
"songs/autumn_leaves_vocal.pages/0006.png": "56e0fcb5faf8c5bd514efd6568284d6548708b4fed7d470c138d9478db34eb77",
"songs/autumn_leaves_vocal.pages/0007.png": "81b80d0a70883a69e10b8f06fd2c2f904670dc3a1cae13b980d239ae4a882e46",
"songs/autumn_leaves_vocal.pages/0008.png": "d55633751f78440bd94f1dbbed846c151cf79bebc5fa4b0f338add0c4b6d2e99",
"songs/autumn_leaves_vocal.pages/0009.png": "2da18b1db70f1cff719f697c39ecda329dbc60d3fe0d605351fcae8583e6901a",
"songs/autumn_leaves_vocal.pages/0010.png": "321d18c54e138248b31a00df6e3a95b9af62588a227a83e56db7475acb923d7a",
"songs/autumn_leaves_vocal.pages/0011.png": "c02c41a2ff2b14ec5bde44f22bec03fd71edf2d9e272d2ef0a8434b843a26b6b",
"songs/autumn_leaves_vocal.pages/0012.png": "ac4bf6573e196d5afbb0049bcc54836a7acac6a1e9e8cce48dedad203f4b43e0",
"songs/autumn_leaves_vocal.pages/0013.png": "6c22d2dde54e4e7d6cd4cef815ff4e4a9738cd48707f344e90e75737939f68ec",
"songs/autumn_leaves_vocal.pages/0014.png": "3b602afdbb2705281dc2dc5120427105649c5df1692155540c9ad9adae3621c8",
"songs/autumn_leaves_vocal.pages/0015.png": "59339718670a3c8d97b6efb7acc3e7234cb26b11e135da9f502689c77824be8a",
"songs/autumn_leaves_vocal.pages/playback-0002.png": "c95138bff07d8dae52535a96bb33cc3a31c6509d7da49c60c35a55f2a1072c4c",
"songs/autumn_leaves_vocal.pages/playback-0003.png": "fb030bc5b0aa831b534223b63b07cbadd7ab76ce9376c3bde8965f09e73d41f8",
"songs/autumn_leaves_vocal.pages/playback-0004.png": "250262570aac9adaff71ddde21a61c65550afb46849016dcd5162cfcedb522da",
"songs/autumn_leaves_vocal.pages/playback-0005.png": "4832b81482f73003ec7e2990f1558ac8d4cc62883ad1c01d8e78837a37a6de6c",
"songs/autumn_leaves_vocal.pages/playback-0006.png": "0b753c25706437aa8b716f9a254e5dfc03c0c441dd2dfc4f038c3fba7528dd19",
"songs/autumn_leaves_vocal.pages/playback-0007.png": "9f9cfa20a8dfa15cd5ac9e0c47f5d6c0632177702229d5f5c7566c315aa99459",
"songs/autumn_leaves_vocal.pages/playback-0008.png": "d525a0af7688927b7cb5f14302ab7151b9e2d0dd83d04d4b53ac7414f8c928e4",
"songs/autumn_leaves_vocal.pages/playback-0009.png": "f6dbfeca5e20dcf475f8da0285632b317759e1d2d4d69a54a4232d853db68004",
"songs/autumn_leaves_vocal.pages/playback-0010.png": "b54d909532c99f359dded5fc2cb11f93309bd8a158b500ee3be524338e471f48",
"songs/autumn_leaves_vocal.pages/playback-0011.png": "619b4621c5ade46fd29e5e70f08c8d421e1916e0578fa2979bcb4a8204dd99d7",
"songs/autumn_leaves_vocal.pages/playback-0012.png": "4472606d66ca6d1a6d490e89f127327e121331eba14c89b1f20e108ed63cb2af",
"songs/autumn_leaves_vocal.pages/playback-0013.png": "525be592c83a6a119c1b2bc8f0eba24de0bac7ac66c56899f10921e959ae7290",
"songs/autumn_leaves_vocal.pages/playback-0014.png": "8f792dd41bad096867edf9e51fcbe5e6773db1256e849cc32b9402aed59ca991",
"songs/autumn_leaves_vocal.pages/playback-0015.png": "2767f2d9979040258f3a619274e6f11fc8e750b7dc3f0d34adbf373455c08200",
"songs/autumn_leaves_vocal.pages/playback-0016.png": "8a61095e966c67c35df99da5fe32d0bb710c9e0443f135585593100a3b76a8bd",
"songs/autumn_leaves_vocal.pages/playback-0017.png": "234f9045d2cc31fc73f1c11bed69641acc5887ac794d051760ba041df5279b8f",
"songs/autumn_leaves_vocal.pages/playback-0018.png": "6d3156bc58fbcd88abc9c5b18e41ab62a7fece8996655c6723a52501869a60a3",
"songs/autumn_leaves_vocal.pages/playback-0019.png": "9851262495f7b0916982a8f46421948068d4208af9ec1d7ff22fd38a80860d24",
"songs/autumn_leaves_vocal.pages/playback-0020.png": "03026a3ab967ac2c963336c475f9ecb3033c799ae28d9e832a2e9d3ac5367c99",
"songs/autumn_leaves_vocal.pages/playback-0021.png": "d72cd7ed7c58cd4fd44372f6dfcef7e91b84b3c74f747bf1fc84a2c81c44da36",
"songs/autumn_leaves_vocal.pages/playback-0022.png": "46ff7fd7202c1378a9598982379d4d7322f5de934280a78eb5cd76a14f6fad35",
"songs/autumn_leaves_vocal.pages/playback-0023.png": "406830c4e11ecc52774bf67351a2d6c3410f2606df206c43cafa44c5beed42cf",
"songs/autumn_leaves_vocal.pages/playback-0024.png": "072dda94b8a251c775663f81b4b264edac9bdb64c67d115fd87ae469eb7b812f",
"songs/autumn_leaves_vocal.pages/playback-0025.png": "b98a8b5879620dfe0bccd063f8d6122fc6826b86b11d07da3aa7abb8a07f265d",
"songs/autumn_leaves_vocal.pages/playback-0026.png": "476255d5814bba2f838f9addc852f080408c24fa3d1c2b549c93da3d59bc11bb",
"songs/autumn_leaves_vocal.pages/playback-0027.png": "0678a2cf026519a06e62c8ef3599a429290092968785e71431584634bba26375",
"songs/autumn_leaves_vocal.pages/playback-0028.png": "d89db0cd8a2435a978a2e11b070c7aaacb3f791542aed8da4b3e17af888ceee5",
"songs/autumn_leaves_vocal.pages/playback-0029.png": "b8ae37ad031080e1a70f8113123e6706455798e51e8dae87913381f846661e08",
"songs/autumn_leaves_vocal.pages/playback-0030.png": "c04d59f0f791b9d813ba659fdb4bd616b649afca3a79b9ed437781f2301f04d8",
"sounds/beat1.wav": "634e73055b4ae05857de54c0cff0bee2ac4476dcde874b474b192512ba113a39",
"sounds/beat2.wav": "9bc510822228479fe4d4fd85be0d3b2f9eac009449f7fd6f916dd54661124c34"
}
+16725
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -11,7 +11,6 @@ import generator.harmonic_time
import generator.solo.types import generator.solo.types
import generator.solo.placement import generator.solo.placement
import generator.solo.profiles import generator.solo.profiles
import generator.solo.decoder
import generator.solo.learned import generator.solo.learned
import generator.solo.harmonic import generator.solo.harmonic
import dataclasses import dataclasses
@@ -45,6 +44,6 @@ def install(test):
test.enterContext(unittest.mock.patch('generator.production.generate_tree', side_effect=serial)) test.enterContext(unittest.mock.patch('generator.production.generate_tree', side_effect=serial))
test.enterContext(unittest.mock.patch('generator.vocabulary.load', return_value=resources)) test.enterContext(unittest.mock.patch('generator.vocabulary.load', return_value=resources))
test.enterContext(unittest.mock.patch('generator.solo.authored.lookup', return_value=None)) test.enterContext(unittest.mock.patch('generator.solo.authored.lookup', return_value=None))
test.enterContext(unittest.mock.patch('generator.production._solo_model', return_value=predictor)) test.enterContext(unittest.mock.patch('generator.composition._solo_model', return_value=predictor))
test.enterContext(unittest.mock.patch('generator.production._resource_key', return_value={'model': 'synthetic infrastructure fixture'})) test.enterContext(unittest.mock.patch('generator.composition._resource_key', return_value={'model': 'synthetic infrastructure fixture'}))
test.enterContext(unittest.mock.patch('generator.solo.composer.compose', side_effect=compose)) test.enterContext(unittest.mock.patch('generator.solo.composer.compose', side_effect=compose))
+2 -2
View File
@@ -1,6 +1,6 @@
import copy, fractions import copy, fractions
import generator.model, generator.parts, generator.harmony import generator.model, generator.parts, generator.harmony
import generator.solo.analysis, generator.solo.composer, generator.solo.types import generator.source, generator.solo.composer, generator.solo.types
import tests.test_musicxml import tests.test_musicxml
import generator.harmonic_time import generator.harmonic_time
@@ -20,7 +20,7 @@ def song(meter=4, count=6, chords=('Dm7', 'G7', 'CM7')):
return generator.parts.prepare_document(document) return generator.parts.prepare_document(document)
def compose(document, part='acoustic', cutoff=None): def compose(document, part='acoustic', cutoff=None):
analysis = generator.solo.analysis.analyze(document) analysis = generator.source.analyze(document)
measures = document['staves'][0]['measures'] measures = document['staves'][0]['measures']
end = sum(generator.model.fraction(m['duration']) for m in measures) if cutoff is None else cutoff end = sum(generator.model.fraction(m['duration']) for m in measures) if cutoff is None else cutoff
return generator.solo.composer.compose(analysis, measures, part, end) return generator.solo.composer.compose(analysis, measures, part, end)
+5 -5
View File
@@ -1,10 +1,10 @@
import generator.validation
import copy import copy
import fractions import fractions
import pathlib import pathlib
import unittest import unittest
import unittest.mock import unittest.mock
import generator.accompaniment
import generator.acoustic import generator.acoustic
import generator.arrange.generate import generator.arrange.generate
import generator.engraving.fonts import generator.engraving.fonts
@@ -41,19 +41,19 @@ def acoustic_document(bars):
for note in notes: for note in notes:
note.update(voice=lane, stem='down' if lane == 'bass' else 'up') note.update(voice=lane, stem='down' if lane == 'bass' else 'up')
measure['voices'].append({'id': lane, 'notes': notes}) measure['voices'].append({'id': lane, 'notes': notes})
generator.model.validate_document(document) generator.validation.validate_document(document)
return document return document
class AcousticTests(unittest.TestCase): class AcousticTests(unittest.TestCase):
def combine(self, document): def combine(self, document):
generator.acoustic.prepare(document) generator.acoustic.prepare(document)
generator.model.validate_document(document) generator.validation.validate_document(document)
prepared = copy.deepcopy(document) prepared = copy.deepcopy(document)
generator.acoustic.prepare(document) generator.acoustic.prepare(document)
self.assertEqual(document, prepared) self.assertEqual(document, prepared)
before = sounding(document) before = sounding(document)
generator.acoustic.combine(document) generator.acoustic.combine(document)
generator.model.validate_document(document) generator.validation.validate_document(document)
self.assertEqual(sounding(document), before) self.assertEqual(sounding(document), before)
self.assertTrue(all(len(m['voices']) == 1 for m in document['staves'][0]['measures'])) self.assertTrue(all(len(m['voices']) == 1 for m in document['staves'][0]['measures']))
for measure in document['staves'][0]['measures']: for measure in document['staves'][0]['measures']:
@@ -130,7 +130,7 @@ class AcousticTests(unittest.TestCase):
separate = builder(source, 'acoustic') separate = builder(source, 'acoustic')
combined = builder(source, 'acoustic') combined = builder(source, 'acoustic')
self.assertEqual(sounding(separate), sounding(combined)) self.assertEqual(sounding(separate), sounding(combined))
generator.model.validate_document(combined) generator.validation.validate_document(combined)
for measure in combined['staves'][0]['measures']: for measure in combined['staves'][0]['measures']:
self.assertIn(len(measure['voices']), (1, 2)) self.assertIn(len(measure['voices']), (1, 2))
if len(measure['voices']) == 2: if len(measure['voices']) == 2:
+2 -1
View File
@@ -1,4 +1,5 @@
"""Acoustic attack assignment across musical, rather than storage, boundaries.""" """Acoustic attack assignment across musical, rather than storage, boundaries."""
import generator.validation
import copy import copy
import dataclasses import dataclasses
import fractions import fractions
@@ -108,7 +109,7 @@ class AcousticPairs(unittest.TestCase):
source['staves'][0]['measures'] = rendered source['staves'][0]['measures'] = rendered
source['staves'][0].update(arrangement_part_type='acoustic', tablature=True) source['staves'][0].update(arrangement_part_type='acoustic', tablature=True)
generator.acoustic.combine(source) generator.acoustic.combine(source)
generator.model.validate_document(source) generator.validation.validate_document(source)
generator.fretboard.validate_document(source) generator.fretboard.validate_document(source)
generator.acoustic.validate_comping(source) generator.acoustic.validate_comping(source)
attacks = [n for n in generator.score.measure_notes(rendered[2]) if n['pitches']] attacks = [n for n in generator.score.measure_notes(rendered[2]) if n['pitches']]
+3 -4
View File
@@ -1,3 +1,4 @@
import generator.validation
import copy import copy
import fractions import fractions
import itertools import itertools
@@ -7,11 +8,9 @@ import unittest
import generator.accompaniment import generator.accompaniment
import generator.acoustic import generator.acoustic
import generator.acoustic_voicing import generator.acoustic_voicing
import generator.arrange.generate
import generator.fretboard import generator.fretboard
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.musicxml
import tests.test_acoustic import tests.test_acoustic
import tests.test_accompaniment import tests.test_accompaniment
@@ -79,7 +78,7 @@ class AcousticVoicingTests(unittest.TestCase):
self.assertIn(strings, ([5], [6])) self.assertIn(strings, ([5], [6]))
self.assertFalse(any(k.startswith('_') for k in note)) self.assertFalse(any(k.startswith('_') for k in note))
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches'])) self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches']))
generator.model.validate_document(document) generator.validation.validate_document(document)
return spans return spans
def test_independent_attacks_keep_one_complete_voicing(self): def test_independent_attacks_keep_one_complete_voicing(self):
document = planned_document([[(0, 'FM7')]]) document = planned_document([[(0, 'FM7')]])
@@ -89,7 +88,7 @@ class AcousticVoicingTests(unittest.TestCase):
before = tests.test_acoustic.sounding(document) before = tests.test_acoustic.sounding(document)
generator.acoustic.combine(document) generator.acoustic.combine(document)
self.assertEqual(tests.test_acoustic.sounding(document), before) self.assertEqual(tests.test_acoustic.sounding(document), before)
generator.model.validate_document(document) generator.validation.validate_document(document)
def test_four_strings_allow_all_defined_bass_inversions(self): def test_four_strings_allow_all_defined_bass_inversions(self):
for symbol in ('CM', 'Dm', 'Caug', 'Cdim', 'C7', 'Cdim7', 'C7no5', 'C7b9', 'C7no1', 'C6', 'C13', 'Csus', 'Csus2'): for symbol in ('CM', 'Dm', 'Caug', 'Cdim', 'C7', 'Cdim7', 'C7no5', 'C7b9', 'C7no1', 'C6', 'C13', 'Csus', 'Csus2'):
with self.subTest(chord=symbol): with self.subTest(chord=symbol):
+7 -5
View File
@@ -1,11 +1,14 @@
"""The held recording tail has exact score time and no vocal attacks.""" """The held recording tail has exact score time and no vocal attacks."""
import generator.arrange.form
import generator.composition
import generator.source
import generator.validation
import copy import copy
import fractions import fractions
import pathlib import pathlib
import unittest import unittest
import generator.acoustic import generator.acoustic
import generator.arrange.ending
import generator.arrange.generate import generator.arrange.generate
import generator.fretboard import generator.fretboard
import generator.harmony import generator.harmony
@@ -13,7 +16,6 @@ import generator.model
import generator.musicxml import generator.musicxml
import generator.parts import generator.parts
import generator.performance import generator.performance
import generator.production
import generator.score import generator.score
import tests.solo_fixtures import tests.solo_fixtures
@@ -45,7 +47,7 @@ class ArrangementEnding(unittest.TestCase):
self.assertEqual(generator.model.fraction(timing['sustain_start']), 672) self.assertEqual(generator.model.fraction(timing['sustain_start']), 672)
self.assertEqual(generator.model.fraction(timing['duration']), 684) self.assertEqual(generator.model.fraction(timing['duration']), 684)
self.assertEqual(len(timing['clicks']), 672) self.assertEqual(len(timing['clicks']), 672)
generator.model.validate_document(result) generator.validation.validate_document(result)
generator.fretboard.validate_document(result) generator.fretboard.validate_document(result)
self.assertEqual(source, before) self.assertEqual(source, before)
def test_ending_uses_final_meter_and_does_not_extend_solo_windows(self): def test_ending_uses_final_meter_and_does_not_extend_solo_windows(self):
@@ -54,10 +56,10 @@ class ArrangementEnding(unittest.TestCase):
prepared = generator.parts.prepare_document(source) prepared = generator.parts.prepare_document(source)
for part in ('acoustic', 'electric'): for part in ('acoustic', 'electric'):
role = part + '_accompaniment' role = part + '_accompaniment'
result = generator.production.generate_role(generator.production.SourceAnalysis.read(prepared), role, pathlib.Path('data')) result = generator.composition.generate_role(generator.source.SourceAnalysis.read(prepared), role, pathlib.Path('data'))
tail = result['document']['staves'][0]['measures'][-3:] tail = result['document']['staves'][0]['measures'][-3:]
self.assertEqual([generator.model.fraction(m['duration']) for m in tail], [meter] * 3) self.assertEqual([generator.model.fraction(m['duration']) for m in tail], [meter] * 3)
windows = generator.production.windows(prepared, part) windows = generator.arrange.form.windows(prepared, part)
self.assertEqual(windows[-1][2], role) self.assertEqual(windows[-1][2], role)
self.assertEqual(windows[-1][1] - windows[-1][0], 3 * meter) self.assertEqual(windows[-1][1] - windows[-1][0], 3 * meter)
self.assertEqual(sum(b - a for a, b, r in windows if r == part + '_solo'), 2 * meter) self.assertEqual(sum(b - a for a, b, r in windows if r == part + '_solo'), 2 * meter)
+6 -6
View File
@@ -1,9 +1,9 @@
import generator.validation
import copy import copy
import fractions import fractions
import pathlib import pathlib
import unittest import unittest
import generator.arrange.form
import generator.arrange.generate import generator.arrange.generate
import generator.arrange.pickup import generator.arrange.pickup
import generator.model import generator.model
@@ -29,7 +29,7 @@ class ArrangementPickupTests(unittest.TestCase):
for part in generator.parts.PART_TYPES: for part in generator.parts.PART_TYPES:
with self.subTest(song=path.stem, part=part): with self.subTest(song=path.stem, part=part):
result = generator.arrange.generate.arrange_document(document, part) result = generator.arrange.generate.arrange_document(document, part)
generator.model.validate_document(result) generator.validation.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 171) self.assertEqual(len(measures), 171)
self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 172)]) self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 172)])
@@ -57,7 +57,7 @@ class ArrangementPickupTests(unittest.TestCase):
for part, boundaries in (('vocal', (1, 5)), ('electric', (2,))): for part, boundaries in (('vocal', (1, 5)), ('electric', (2,))):
with self.subTest(part=part): with self.subTest(part=part):
result = generator.arrange.generate._arrange_document(generator.parts.prepare_document(document), part, owned={'melody', 'vocal'}) result = generator.arrange.generate._arrange_document(generator.parts.prepare_document(document), part, owned={'melody', 'vocal'})
generator.model.validate_document(result) generator.validation.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 9) self.assertEqual(len(measures), 9)
for boundary in boundaries: for boundary in boundaries:
@@ -84,13 +84,13 @@ class ArrangementPickupTests(unittest.TestCase):
self.assertEqual(notes[1]['lyrics'][0]['syllables'][0]['text'], 'doo') self.assertEqual(notes[1]['lyrics'][0]['syllables'][0]['text'], 'doo')
self.assertEqual([generator.model.fraction(n['onset']) for n in notes], [0, 1]) self.assertEqual([generator.model.fraction(n['onset']) for n in notes], [0, 1])
document['staves'][0]['measures'] = [destination] document['staves'][0]['measures'] = [destination]
generator.model.validate_document(document) generator.validation.validate_document(document)
def test_pickup_harmonies_are_planned_for_every_part_at_the_same_offset(self): def test_pickup_harmonies_are_planned_for_every_part_at_the_same_offset(self):
document = source(tests.test_parts.xml_harmony('F') + tests.test_musicxml.xml_note('F', 1, 'quarter')) document = source(tests.test_parts.xml_harmony('F') + tests.test_musicxml.xml_note('F', 1, 'quarter'))
prepared = generator.parts.prepare_document(document) prepared = generator.parts.prepare_document(document)
for part in generator.parts.PART_TYPES: for part in generator.parts.PART_TYPES:
result = generator.arrange.generate.arrange_document(document, part) result = generator.arrange.generate.arrange_document(document, part)
generator.model.validate_document(result) generator.validation.validate_document(result)
for index in (0, 1, 4): for index in (0, 1, 4):
chords = result['staves'][0]['measures'][index]['harmonies'] chords = result['staves'][0]['measures'][index]['harmonies']
self.assertEqual([(generator.model.fraction(h['onset']), h['root']['letter']) for h in chords], [(0, 'c'), (3, 'f')]) self.assertEqual([(generator.model.fraction(h['onset']), h['root']['letter']) for h in chords], [(0, 'c'), (3, 'f')])
@@ -102,7 +102,7 @@ class ArrangementPickupTests(unittest.TestCase):
document = source(notes, divisions=3) document = source(notes, divisions=3)
for part in ('vocal', 'electric'): for part in ('vocal', 'electric'):
result = generator.arrange.generate.arrange_document(document, part) result = generator.arrange.generate.arrange_document(document, part)
generator.model.validate_document(result) generator.validation.validate_document(result)
spans = result['staves'][0]['tuplets'] spans = result['staves'][0]['tuplets']
self.assertEqual(len(spans), 2 if part == 'vocal' else 1) self.assertEqual(len(spans), 2 if part == 'vocal' else 1)
self.assertEqual(len({s['id'] for s in spans}), len(spans)) self.assertEqual(len({s['id'] for s in spans}), len(spans))
+2 -2
View File
@@ -1,10 +1,10 @@
import generator.production
import tests.production_fixtures import tests.production_fixtures
import json import json
import pathlib import pathlib
import tempfile import tempfile
import unittest import unittest
import generator.arrange.generate
import tests.solo_fixtures import tests.solo_fixtures
import tests.test_musicxml import tests.test_musicxml
@@ -16,7 +16,7 @@ class CatalogTests(unittest.TestCase):
source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output' source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output'
source.mkdir() source.mkdir()
(source / 'song.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>E</root-step><root-alter>-1</root-alter></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>')) (source / 'song.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>E</root-step><root-alter>-1</root-alter></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output), 3) self.assertEqual(generator.production.generate_tree(source, output), 3)
self.assertEqual({p.name for p in output.iterdir()}, {'song_acoustic.json', 'song_electric.json', 'song_vocal.json'}) self.assertEqual({p.name for p in output.iterdir()}, {'song_acoustic.json', 'song_electric.json', 'song_vocal.json'})
for path in output.iterdir(): for path in output.iterdir():
metadata = json.loads(path.read_text())['metadata'] metadata = json.loads(path.read_text())['metadata']
+5 -6
View File
@@ -1,18 +1,17 @@
"""General chord families and one-voice acoustic rhythm, not song overrides.""" """General chord families and one-voice acoustic rhythm, not song overrides."""
import generator.composition
import generator.validation
import copy import copy
import fractions import fractions
import unittest import unittest
import generator.acoustic import generator.acoustic
import generator.accompaniment
import generator.chord_families import generator.chord_families
import generator.comping import generator.comping
import generator.comping_rhythm import generator.comping_rhythm
import generator.fretboard import generator.fretboard
import generator.guitar
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.production
import generator.vocabulary import generator.vocabulary
import tests.solo_fixtures import tests.solo_fixtures
import tests.test_acoustic import tests.test_acoustic
@@ -89,8 +88,8 @@ class ChordFamilies(unittest.TestCase):
changed = copy.deepcopy(resources) changed = copy.deepcopy(resources)
changed['accompaniment']['instruments']['acoustic']['patterns'][0]['events'][0]['duration'] = '1/2' changed['accompaniment']['instruments']['acoustic']['patterns'][0]['events'][0]['duration'] = '1/2'
for role in ('electric_accompaniment', 'electric_melody', 'electric_solo', 'vocal_melody'): for role in ('electric_accompaniment', 'electric_melody', 'electric_solo', 'vocal_melody'):
self.assertEqual(generator.production.dependencies(role, resources), generator.production.dependencies(role, changed)) self.assertEqual(generator.composition.dependencies(role, resources), generator.composition.dependencies(role, changed))
self.assertNotEqual(generator.production.dependencies('acoustic_accompaniment', resources), generator.production.dependencies('acoustic_accompaniment', changed)) self.assertNotEqual(generator.composition.dependencies('acoustic_accompaniment', resources), generator.composition.dependencies('acoustic_accompaniment', changed))
class CombinedAcoustic(unittest.TestCase): class CombinedAcoustic(unittest.TestCase):
def document(self, bars): def document(self, bars):
@@ -107,7 +106,7 @@ class CombinedAcoustic(unittest.TestCase):
def convert(self, document): def convert(self, document):
before = tests.test_acoustic.sounding(document) before = tests.test_acoustic.sounding(document)
generator.acoustic.combine(document) generator.acoustic.combine(document)
generator.model.validate_document(document) generator.validation.validate_document(document)
generator.fretboard.validate_document(document) generator.fretboard.validate_document(document)
self.assertEqual(tests.test_acoustic.sounding(document), before) self.assertEqual(tests.test_acoustic.sounding(document), before)
self.assertTrue(all(len(m['voices']) == 1 for m in document['staves'][0]['measures'])) self.assertTrue(all(len(m['voices']) == 1 for m in document['staves'][0]['measures']))
+13 -9
View File
@@ -1,4 +1,8 @@
"""Recurring source-position grooves, fills and chord-first bass behavior.""" """Recurring source-position grooves, fills and chord-first bass behavior."""
import generator.composition_types
import generator.composition
import generator.source
import generator.validation
import concurrent.futures import concurrent.futures
import copy import copy
import dataclasses import dataclasses
@@ -213,7 +217,7 @@ class RecurringGrooves(unittest.TestCase):
source['staves'][0].update(arrangement_part_type='acoustic', tablature=True) source['staves'][0].update(arrangement_part_type='acoustic', tablature=True)
generator.acoustic.validate_comping(source) generator.acoustic.validate_comping(source)
generator.acoustic.combine(source) generator.acoustic.combine(source)
generator.model.validate_document(source) generator.validation.validate_document(source)
generator.fretboard.validate_document(source) generator.fretboard.validate_document(source)
generator.acoustic.validate_comping(source) generator.acoustic.validate_comping(source)
first = source['staves'][0]['measures'][0] first = source['staves'][0]['measures'][0]
@@ -239,26 +243,26 @@ class RecurringGrooves(unittest.TestCase):
r = generator.comping_rhythm.realize(pattern, inputs, 'acoustic', generator.comping_rhythm.melody_contexts(source)) r = generator.comping_rhythm.realize(pattern, inputs, 'acoustic', generator.comping_rhythm.melody_contexts(source))
self.assertTrue(all(e.lane == 'combined' for e in r.events)) self.assertTrue(all(e.lane == 'combined' for e in r.events))
def test_clean_cached_parallel_and_other_role_settings_keep_groove(self): def test_clean_cached_parallel_and_other_role_settings_keep_groove(self):
source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=10, chords=('C7', 'FM7'))) source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=10, chords=('C7', 'FM7')))
root = pathlib.Path('data').resolve() root = pathlib.Path('data').resolve()
roles = ('acoustic_accompaniment', 'electric_accompaniment') roles = ('acoustic_accompaniment', 'electric_accompaniment')
clean = {r: generator.production.generate_role(source, r, root) for r in roles} clean = {r: generator.composition.generate_role(source, r, root) for r in roles}
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
for r in roles: for r in roles:
cold = generator.production.generate_role(source, r, root, cache_dir=directory) cold = generator.composition.generate_role(source, r, root, cache_dir=directory)
warm = generator.production.generate_role(source, r, root, cache_dir=directory) warm = generator.composition.generate_role(source, r, root, cache_dir=directory)
self.assertTrue(warm['cache_hit']) self.assertTrue(warm['cache_hit'])
self.assertEqual(clean[r]['document'], cold['document']) self.assertEqual(clean[r]['document'], cold['document'])
self.assertEqual(clean[r]['document'], warm['document']) self.assertEqual(clean[r]['document'], warm['document'])
generator.production.generate_role(source, roles[1], root, {'seed': 'different'}, directory) generator.composition.generate_role(source, roles[1], root, {'seed': 'different'}, directory)
self.assertEqual(clean[roles[0]]['document'], generator.production.generate_role(source, roles[0], root, cache_dir=directory)['document']) self.assertEqual(clean[roles[0]]['document'], generator.composition.generate_role(source, roles[0], root, cache_dir=directory)['document'])
with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool: with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool:
futures = {r: pool.submit(generator.production._job, (source, r, root, {}, None)) for r in reversed(roles)} futures = {r: pool.submit(generator.production._job, generator.composition_types.RoleRequest(source, r, root)) for r in reversed(roles)}
for r, future in futures.items(): for r, future in futures.items():
self.assertEqual(clean[r]['document'], future.result()['document']) self.assertEqual(clean[r]['document'], future.result()['document'])
def test_reuse_audit_checks_rests_and_all_six_roles(self): def test_reuse_audit_checks_rests_and_all_six_roles(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',)) source = tests.solo_fixtures.song(count=2, chords=('C7',))
for role in generator.production.ROLES: for role in generator.composition_types.ROLES:
part = role.split('_')[0] part = role.split('_')[0]
wanted = 'slash' if role.endswith('_solo') else 'vocal' if part == 'vocal' else 'melody' if role.endswith('_melody') else 'accompaniment' wanted = 'slash' if role.endswith('_solo') else 'vocal' if part == 'vocal' else 'melody' if role.endswith('_melody') else 'accompaniment'
section = generator.arrange.form.ArrangementSection('Repeated role', 0, 2, 1, wanted) section = generator.arrange.form.ArrangementSection('Repeated role', 0, 2, 1, wanted)
+11 -8
View File
@@ -1,4 +1,7 @@
"""Meter-specific vocabulary and source-derived quintuple grouping.""" """Meter-specific vocabulary and source-derived quintuple grouping."""
import generator.composition
import generator.harmony
import generator.source
import concurrent.futures import concurrent.futures
import copy import copy
import fractions import fractions
@@ -159,15 +162,15 @@ class CompingMeters(unittest.TestCase):
def test_unused_meter_and_other_role_data_do_not_reseed(self): def test_unused_meter_and_other_role_data_do_not_reseed(self):
resources = generator.vocabulary.load() resources = generator.vocabulary.load()
measures = self.source(4)['staves'][0]['measures'] measures = self.source(4)['staves'][0]['measures']
baseline = generator.production.dependencies('acoustic_accompaniment', resources, measures) baseline = generator.composition.dependencies('acoustic_accompaniment', resources, measures)
changed = copy.deepcopy(resources) changed = copy.deepcopy(resources)
for role, policy in changed['accompaniment']['instruments'].items(): for role, policy in changed['accompaniment']['instruments'].items():
for p in policy['patterns']: for p in policy['patterns']:
if role == 'electric' or p['meter'] in ('3/4', '5/4'): if role == 'electric' or p['meter'] in ('3/4', '5/4'):
p['events'] = [] p['events'] = []
self.assertEqual(baseline, generator.production.dependencies('acoustic_accompaniment', changed, measures)) self.assertEqual(baseline, generator.composition.dependencies('acoustic_accompaniment', changed, measures))
changed['accompaniment']['instruments']['acoustic']['patterns'][0]['events'] = [] changed['accompaniment']['instruments']['acoustic']['patterns'][0]['events'] = []
self.assertNotEqual(baseline, generator.production.dependencies('acoustic_accompaniment', changed, measures)) self.assertNotEqual(baseline, generator.composition.dependencies('acoustic_accompaniment', changed, measures))
def test_chord_changes_anticipations_and_cross_bar_ties(self): def test_chord_changes_anticipations_and_cross_bar_ties(self):
for beats, grouping in ((4, ()), (3, ()), (5, (3, 2)), (5, (2, 3))): for beats, grouping in ((4, ()), (3, ()), (5, (3, 2)), (5, (2, 3))):
source = self.source(beats, 2, grouping) source = self.source(beats, 2, grouping)
@@ -188,18 +191,18 @@ class CompingMeters(unittest.TestCase):
self.assertTrue(anticipated) self.assertTrue(anticipated)
def test_roles_are_deterministic_alone_cached_and_parallel(self): def test_roles_are_deterministic_alone_cached_and_parallel(self):
for meter in (3, 5): for meter in (3, 5):
source = generator.production.SourceAnalysis.read(self.source(meter, 9)) source = generator.source.SourceAnalysis.read(self.source(meter, 9))
roles = ('acoustic_accompaniment', 'electric_accompaniment') roles = ('acoustic_accompaniment', 'electric_accompaniment')
with tempfile.TemporaryDirectory() as cache: with tempfile.TemporaryDirectory() as cache:
clean = {r: generator.production.generate_role(source, r, pathlib.Path('data')) for r in roles} clean = {r: generator.composition.generate_role(source, r, pathlib.Path('data')) for r in roles}
for role in reversed(roles): for role in reversed(roles):
cold = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=cache) cold = generator.composition.generate_role(source, role, pathlib.Path('data'), cache_dir=cache)
warm = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=cache) warm = generator.composition.generate_role(source, role, pathlib.Path('data'), cache_dir=cache)
self.assertEqual(clean[role]['document'], cold['document']) self.assertEqual(clean[role]['document'], cold['document'])
self.assertEqual(cold['document'], warm['document']) self.assertEqual(cold['document'], warm['document'])
self.assertTrue(warm['cache_hit']) self.assertTrue(warm['cache_hit'])
with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool: with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool:
jobs = {r: pool.submit(generator.production._job, (source, r, pathlib.Path('data'), {}, None)) for r in reversed(roles)} jobs = {r: pool.submit(generator.production._job, generator.composition_types.RoleRequest(source, r, pathlib.Path('data'))) for r in reversed(roles)}
for role, job in jobs.items(): for role, job in jobs.items():
self.assertEqual(clean[role]['document'], job.result()['document']) self.assertEqual(clean[role]['document'], job.result()['document'])
+13 -10
View File
@@ -1,4 +1,8 @@
"""Independent rhythm contracts and their complete sounding durations.""" """Independent rhythm contracts and their complete sounding durations."""
import generator.composition_types
import generator.arrange.assembly
import generator.composition
import generator.validation
import copy import copy
import dataclasses import dataclasses
import fractions import fractions
@@ -14,9 +18,8 @@ import generator.fretboard
import generator.harmonic_time import generator.harmonic_time
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.production
import generator.score import generator.score
import generator.solo.analysis import generator.source
import generator.solo.decoder import generator.solo.decoder
import generator.solo.harmonic import generator.solo.harmonic
import generator.solo.learned import generator.solo.learned
@@ -187,17 +190,17 @@ class LayeredComping(unittest.TestCase):
changed = copy.deepcopy(self.source) changed = copy.deepcopy(self.source)
changed['metadata']['tempo'] = 240 changed['metadata']['tempo'] = 240
changed['staves'][0]['measures'][0]['tempos'] = [{'onset': generator.model.rational(0), 'bpm': generator.model.rational(240), 'written': {'type': 'quarter', 'dots': 0}}] changed['staves'][0]['measures'][0]['tempos'] = [{'onset': generator.model.rational(0), 'bpm': generator.model.rational(240), 'written': {'type': 'quarter', 'dots': 0}}]
a, b = map(generator.production.SourceAnalysis.read, (self.source, changed)) a, b = map(generator.source.SourceAnalysis.read, (self.source, changed))
self.assertEqual(a.accompaniment_key(), b.accompaniment_key()) self.assertEqual(a.accompaniment_key(), b.accompaniment_key())
for role in ('acoustic_accompaniment', 'electric_accompaniment'): for role in ('acoustic_accompaniment', 'electric_accompaniment'):
first = generator.production.generate_role(a, role, pathlib.Path('data')) first = generator.composition.generate_role(a, role, pathlib.Path('data'))
second = generator.production.generate_role(b, role, pathlib.Path('data')) second = generator.composition.generate_role(b, role, pathlib.Path('data'))
self.assertEqual(sounding(first['document']), sounding(second['document'])) self.assertEqual(sounding(first['document']), sounding(second['document']))
self.assertEqual(first['details'], second['details']) self.assertEqual(first['details'], second['details'])
self.assertNotEqual(first['trace']['cache_key'], second['trace']['cache_key']) self.assertNotEqual(first['trace']['cache_key'], second['trace']['cache_key'])
def test_assembly_keeps_releases_and_canonical_reuse(self): def test_assembly_keeps_releases_and_canonical_reuse(self):
source = generator.production.SourceAnalysis.read(self.source) source = generator.source.SourceAnalysis.read(self.source)
results = {role: generator.production.generate_role(source, role, pathlib.Path('data')) for role in generator.production.ROLES if not role.endswith('_solo')} results = {role: generator.composition.generate_role(source, role, pathlib.Path('data')) for role in generator.composition_types.ROLES if not role.endswith('_solo')}
# Silence in source-defined solo slots is sufficient to test union; # Silence in source-defined solo slots is sufficient to test union;
# no generated solo participates in these accompaniment decisions. # no generated solo participates in these accompaniment decisions.
for part in ('acoustic', 'electric'): for part in ('acoustic', 'electric'):
@@ -207,12 +210,12 @@ class LayeredComping(unittest.TestCase):
for n in v['notes']: for n in v['notes']:
n.update(kind='rest', pitches=[]) n.update(kind='rest', pitches=[])
results[part + '_solo'] = empty results[part + '_solo'] = empty
assembled = generator.production.assemble(source, results) assembled = generator.arrange.assembly.assemble(source, results)
self.assertTrue(all(len(m['voices']) == 1 for m in assembled['acoustic']['staves'][0]['measures'])) self.assertTrue(all(len(m['voices']) == 1 for m in assembled['acoustic']['staves'][0]['measures']))
audit = generator.arrange.accompaniment.audit(self.source, assembled) audit = generator.arrange.accompaniment.audit(self.source, assembled)
self.assertGreater(audit['acoustic']['reused_bars'], 0) self.assertGreater(audit['acoustic']['reused_bars'], 0)
for doc in assembled.values(): for doc in assembled.values():
generator.model.validate_document(doc) generator.validation.validate_document(doc)
generator.fretboard.validate_document(doc) generator.fretboard.validate_document(doc)
def test_combined_voices_reject_conflicting_strings_and_fret_spans(self): def test_combined_voices_reject_conflicting_strings_and_fret_spans(self):
for string, fret in ((5, 3), (2, 10)): for string, fret in ((5, 3), (2, 10)):
@@ -266,7 +269,7 @@ class SoloRhythmConditioning(unittest.TestCase):
if held: if held:
m = source['staves'][0]['measures'][0] m = source['staves'][0]['measures'][0]
m['voices'][0]['notes'] = [generator.model.make_event('held', fractions.Fraction(), fractions.Fraction(4), 'note', [{'letter': 'c', 'alter': 0, 'octave': 4}])] m['voices'][0]['notes'] = [generator.model.make_event('held', fractions.Fraction(), fractions.Fraction(4), 'note', [{'letter': 'c', 'alter': 0, 'octave': 4}])]
analysis = generator.solo.analysis.analyze(source) analysis = generator.source.analyze(source)
harmony = generator.solo.harmonic.Harmony(generator.harmonic_time.read(source['staves'][0])) harmony = generator.solo.harmonic.Harmony(generator.harmonic_time.read(source['staves'][0]))
examples.append({'analysis': analysis, 'harmony': harmony, 'events': [(fractions.Fraction(), fractions.Fraction(duration), 64), (fractions.Fraction(duration), fractions.Fraction(4), 67)]}) examples.append({'analysis': analysis, 'harmony': harmony, 'events': [(fractions.Fraction(), fractions.Fraction(duration), 64), (fractions.Fraction(duration), fractions.Fraction(4), 67)]})
model = generator.solo.learned.Model(generator.solo.learned.fit(examples, {'purpose': 'independently authored conditional-rhythm test'})) model = generator.solo.learned.Model(generator.solo.learned.fit(examples, {'purpose': 'independently authored conditional-rhythm test'}))
+17 -16
View File
@@ -1,4 +1,6 @@
"""Accompaniment geometry and whole-form movement, using authored small cases.""" """Accompaniment geometry and whole-form movement, using authored small cases."""
import generator.composition
import generator.source
import copy import copy
import fractions import fractions
import itertools import itertools
@@ -14,7 +16,6 @@ import generator.fretboard
import generator.guitar import generator.guitar
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.production
import generator.score import generator.score
import generator.vocabulary import generator.vocabulary
import tests.solo_fixtures import tests.solo_fixtures
@@ -43,13 +44,13 @@ class CompingVoicings(unittest.TestCase):
path, _ = generator.comping.select_grips(((low, high),), (costs,), 'electric', 'lower', left=high, right=high) path, _ = generator.comping.select_grips(((low, high),), (costs,), 'electric', 'lower', left=high, right=high)
self.assertEqual(path, (high,)) self.assertEqual(path, (high,))
def test_electric_voicing_policy_does_not_reseed_rhythm(self): def test_electric_voicing_policy_does_not_reseed_rhythm(self):
source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=4, chords=('C7', 'FM7', 'Dm7', 'G7'))) source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=4, chords=('C7', 'FM7', 'Dm7', 'G7')))
root = pathlib.Path('data') root = pathlib.Path('data')
current = generator.production.generate_role(source, 'electric_accompaniment', root) current = generator.composition.generate_role(source, 'electric_accompaniment', root)
with tempfile.TemporaryDirectory() as cache: with tempfile.TemporaryDirectory() as cache:
settings = {'voicing': {'register_weight': 0, 'high_position_weight': 0}} settings = {'voicing': {'register_weight': 0, 'high_position_weight': 0}}
other = generator.production.generate_role(source, 'electric_accompaniment', root, settings, cache) other = generator.composition.generate_role(source, 'electric_accompaniment', root, settings, cache)
warm = generator.production.generate_role(source, 'electric_accompaniment', root, settings, cache) warm = generator.composition.generate_role(source, 'electric_accompaniment', root, settings, cache)
self.assertTrue(warm['cache_hit']) self.assertTrue(warm['cache_hit'])
self.assertEqual(other['document'], warm['document']) self.assertEqual(other['document'], warm['document'])
self.assertNotEqual(current['trace']['cache_key'], other['trace']['cache_key']) self.assertNotEqual(current['trace']['cache_key'], other['trace']['cache_key'])
@@ -99,7 +100,7 @@ class CompingVoicings(unittest.TestCase):
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'register_weight': -1}}), self.assertRaises(ValueError): with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'register_weight': -1}}), self.assertRaises(ValueError):
generator.comping.local_cost('acoustic', key, high, 53) generator.comping.local_cost('acoustic', key, high, 53)
def test_production_voicing_policy_changes_cache_not_rhythm_or_electric(self): def test_production_voicing_policy_changes_cache_not_rhythm_or_electric(self):
source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=6, chords=('Em7b5', 'A7b9', 'Dm7', 'G7', 'Cm7', 'F7'))) source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=6, chords=('Em7b5', 'A7b9', 'Dm7', 'G7', 'Cm7', 'F7')))
def rhythm(result): def rhythm(result):
return [[(n['onset'], n['duration'], n['written'], n['kind'], len(n['pitches']), tuple((bool(p.get('tie_from')), bool(p.get('tie_to'))) for p in n['pitches'])) for n in generator.score.measure_notes(m)] for m in result['document']['staves'][0]['measures']] return [[(n['onset'], n['duration'], n['written'], n['kind'], len(n['pitches']), tuple((bool(p.get('tie_from')), bool(p.get('tie_to'))) for p in n['pitches'])) for n in generator.score.measure_notes(m)] for m in result['document']['staves'][0]['measures']]
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -109,14 +110,14 @@ class CompingVoicings(unittest.TestCase):
data = json.loads(role_path.read_text()) data = json.loads(role_path.read_text())
policy = data['roles']['acoustic_accompaniment'].pop('voicing') policy = data['roles']['acoustic_accompaniment'].pop('voicing')
role_path.write_text(json.dumps(data)) role_path.write_text(json.dumps(data))
legacy = generator.production.generate_role(source, 'acoustic_accompaniment', root) legacy = generator.composition.generate_role(source, 'acoustic_accompaniment', root)
electric = generator.production.generate_role(source, 'electric_accompaniment', root) electric = generator.composition.generate_role(source, 'electric_accompaniment', root)
data['roles']['acoustic_accompaniment']['voicing'] = policy data['roles']['acoustic_accompaniment']['voicing'] = policy
role_path.write_text(json.dumps(data)) role_path.write_text(json.dumps(data))
current = generator.production.generate_role(source, 'acoustic_accompaniment', root) current = generator.composition.generate_role(source, 'acoustic_accompaniment', root)
settings = {'voicing': {'register_weight': 0, 'high_position_weight': 0, 'comfortable_max_fret': 9}} settings = {'voicing': {'register_weight': 0, 'high_position_weight': 0, 'comfortable_max_fret': 9}}
old = generator.production.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache') old = generator.composition.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache')
warm = generator.production.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache') warm = generator.composition.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache')
self.assertTrue(warm['cache_hit']) self.assertTrue(warm['cache_hit'])
self.assertEqual(old['document'], warm['document']) self.assertEqual(old['document'], warm['document'])
self.assertNotEqual(current['document'], old['document']) self.assertNotEqual(current['document'], old['document'])
@@ -125,7 +126,7 @@ class CompingVoicings(unittest.TestCase):
self.assertEqual(legacy['document'], current['document']) self.assertEqual(legacy['document'], current['document'])
self.assertEqual(rhythm(current), rhythm(old)) self.assertEqual(rhythm(current), rhythm(old))
self.assertEqual(current['details']['accompaniment_rhythm'], old['details']['accompaniment_rhythm']) self.assertEqual(current['details']['accompaniment_rhythm'], old['details']['accompaniment_rhythm'])
self.assertEqual(electric['document'], generator.production.generate_role(source, 'electric_accompaniment', root)['document']) self.assertEqual(electric['document'], generator.composition.generate_role(source, 'electric_accompaniment', root)['document'])
def test_report_balanced_cost_matches_selected_path(self): def test_report_balanced_cost_matches_selected_path(self):
canonical = generator.comping.Canonical(tests.solo_fixtures.song(count=4, chords=('Em7b5', 'A7b9', 'Dm7', 'G7')), 'acoustic', 'report') canonical = generator.comping.Canonical(tests.solo_fixtures.song(count=4, chords=('Em7b5', 'A7b9', 'Dm7', 'G7')), 'acoustic', 'report')
rows = [r for r in canonical.report if r['tones']] rows = [r for r in canonical.report if r['tones']]
@@ -242,13 +243,13 @@ class CompingVoicings(unittest.TestCase):
high = generator.comping.Canonical(source, part, 'fixed-rhythm') high = generator.comping.Canonical(source, part, 'fixed-rhythm')
self.assertEqual(rhythm(low), rhythm(high)) self.assertEqual(rhythm(low), rhythm(high))
def test_accompaniment_cache_matches_clean_generation(self): def test_accompaniment_cache_matches_clean_generation(self):
source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=3)) source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=3))
for part in ('acoustic', 'electric'): for part in ('acoustic', 'electric'):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
role = part + '_accompaniment' role = part + '_accompaniment'
cold = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=directory) cold = generator.composition.generate_role(source, role, pathlib.Path('data'), cache_dir=directory)
warm = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=directory) warm = generator.composition.generate_role(source, role, pathlib.Path('data'), cache_dir=directory)
clean = generator.production.generate_role(source, role, pathlib.Path('data')) clean = generator.composition.generate_role(source, role, pathlib.Path('data'))
self.assertTrue(warm['cache_hit']) self.assertTrue(warm['cache_hit'])
self.assertEqual(cold['document'], warm['document']) self.assertEqual(cold['document'], warm['document'])
self.assertEqual(cold['document'], clean['document']) self.assertEqual(cold['document'], clean['document'])
+2 -2
View File
@@ -1,10 +1,10 @@
import generator.validation
import copy import copy
import fractions import fractions
import pathlib import pathlib
import unittest import unittest
import unittest.mock import unittest.mock
import generator.arrange.form
import generator.arrange.generate import generator.arrange.generate
import generator.electric import generator.electric
import generator.fretboard import generator.fretboard
@@ -110,6 +110,6 @@ class ElectricVoicingTests(unittest.TestCase):
before = copy.deepcopy(source) before = copy.deepcopy(source)
with unittest.mock.patch('generator.electric.reuse_voicings', side_effect=AssertionError('cross-part grip dependency')) as reuse: with unittest.mock.patch('generator.electric.reuse_voicings', side_effect=AssertionError('cross-part grip dependency')) as reuse:
result = generator.arrange.generate.arrange_document(source, 'electric') result = generator.arrange.generate.arrange_document(source, 'electric')
generator.model.validate_document(result) generator.validation.validate_document(result)
reuse.assert_not_called() reuse.assert_not_called()
self.assertEqual(source, before) self.assertEqual(source, before)
+5 -4
View File
@@ -1,3 +1,4 @@
import generator.score_io
import tests.production_fixtures import tests.production_fixtures
import argparse import argparse
import contextlib import contextlib
@@ -19,11 +20,11 @@ import generator.engraving.cache
import generator.engraving.fonts import generator.engraving.fonts
import generator.engraving.layout import generator.engraving.layout
import generator.engraving.pipeline import generator.engraving.pipeline
import generator.engraving.worker
import generator.engraving.raster import generator.engraving.raster
import generator.engraving.types import generator.engraving.types
import generator.engraving.validation import generator.engraving.validation
import generator.pico_artwork import generator.pico_artwork
import generator.score
import tests.solo_fixtures import tests.solo_fixtures
import tests.test_musicxml import tests.test_musicxml
@@ -173,8 +174,8 @@ class EngravingCacheTests(unittest.TestCase):
style = generator.engraving.types.Style() style = generator.engraving.types.Style()
documents = [self.document, tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')))] documents = [self.document, tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')))]
pages = [generator.engraving.layout.layout_score(document, style, family).pages[0] for document in documents] pages = [generator.engraving.layout.layout_score(document, style, family).pages[0] for document in documents]
result = generator.engraving.pipeline.GroupResult() result = generator.engraving.worker.GroupResult()
bands = generator.engraving.pipeline.BodyBands(self.root / 'spool', None, result, limit=1) bands = generator.engraving.worker.BodyBands(self.root / 'spool', None, result, limit=1)
self.addCleanup(bands.close) self.addCleanup(bands.close)
original = None original = None
for index in (0, 1, 0): for index in (0, 1, 0):
@@ -338,7 +339,7 @@ class EngravingCacheTests(unittest.TestCase):
reads.append(path) reads.append(path)
return read_text(path, *args, **kwargs) return read_text(path, *args, **kwargs)
with unittest.mock.patch.object(pathlib.Path, 'read_text', read): with unittest.mock.patch.object(pathlib.Path, 'read_text', read):
generator.score.validate_generated_tree(root) generator.score_io.validate_generated_tree(root)
self.assertCountEqual(reads, sorted(root.rglob('*.json'))) self.assertCountEqual(reads, sorted(root.rglob('*.json')))
def test_invalid_worker_count_and_overlapping_cache_are_rejected(self): def test_invalid_worker_count_and_overlapping_cache_are_rejected(self):
root = self.root / 'assets' root = self.root / 'assets'
+10 -11
View File
@@ -1,3 +1,5 @@
import generator.score_io
import generator.validation
import copy import copy
import fractions import fractions
import pathlib import pathlib
@@ -14,9 +16,6 @@ import generator.fretboard
import generator.guitar import generator.guitar
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.score
import generator.solo.analysis
import generator.solo.placement
import tests.solo_fixtures import tests.solo_fixtures
import tests.test_acoustic_voicing import tests.test_acoustic_voicing
import tests.test_musicxml import tests.test_musicxml
@@ -97,7 +96,7 @@ class FretPolicyTests(unittest.TestCase):
self.assertFalse(generator.fretboard.span_penalty((position(4, 5), position(3, 6)))) self.assertFalse(generator.fretboard.span_penalty((position(4, 5), position(3, 6))))
before = tests.score_events.timeline(source) before = tests.score_events.timeline(source)
generator.guitar.place_guitar(source) generator.guitar.place_guitar(source)
generator.model.validate_document(source) generator.validation.validate_document(source)
generator.fretboard.validate_document(source) generator.fretboard.validate_document(source)
self.assertEqual(tests.score_events.timeline(source), before) self.assertEqual(tests.score_events.timeline(source), before)
self.assertEqual([p['tablature'] for p in measure['voices'][0]['notes'][0]['pitches']], [{'string': 4, 'fret': 5}, {'string': 2, 'fret': 2}]) self.assertEqual([p['tablature'] for p in measure['voices'][0]['notes'][0]['pitches']], [{'string': 4, 'fret': 5}, {'string': 2, 'fret': 2}])
@@ -114,7 +113,7 @@ class FretPolicyTests(unittest.TestCase):
states = generator.guitar.advance_positions([n], fractions.Fraction(2 * i), tones, states, True) states = generator.guitar.advance_positions([n], fractions.Fraction(2 * i), tones, states, True)
self.assertEqual(min(s.cost for s in states.values()).extended_grips, 1) self.assertEqual(min(s.cost for s in states.values()).extended_grips, 1)
generator.guitar.place_guitar(source) generator.guitar.place_guitar(source)
generator.model.validate_document(source) generator.validation.validate_document(source)
generator.fretboard.validate_document(source) generator.fretboard.validate_document(source)
self.assertEqual([p['tablature'] for p in pieces[0]['pitches']], [p['tablature'] for p in pieces[1]['pitches']]) self.assertEqual([p['tablature'] for p in pieces[0]['pitches']], [p['tablature'] for p in pieces[1]['pitches']])
# Removing the low anchor early still leaves an illegal high single tail. # Removing the low anchor early still leaves an illegal high single tail.
@@ -151,7 +150,7 @@ class FretPolicyTests(unittest.TestCase):
source = document(tuple(position(s, f) for s, f in zip((2, 1), frets))) source = document(tuple(position(s, f) for s, f in zip((2, 1), frets)))
if valid: if valid:
generator.guitar.place_guitar(source) generator.guitar.place_guitar(source)
generator.model.validate_document(source) generator.validation.validate_document(source)
generator.fretboard.validate_document(source) generator.fretboard.validate_document(source)
else: else:
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
@@ -162,7 +161,7 @@ class FretPolicyTests(unittest.TestCase):
source = document((position(2, 12), position(1, 14))) source = document((position(2, 12), position(1, 14)))
anchor = tests.test_musicxml.notes(source)[0]['pitches'][0] anchor = tests.test_musicxml.notes(source)[0]['pitches'][0]
anchor.update(duration=generator.model.rational(2), written=generator.model.written_for(fractions.Fraction(2))) anchor.update(duration=generator.model.rational(2), written=generator.model.written_for(fractions.Fraction(2)))
generator.model.validate_document(source) generator.validation.validate_document(source)
with self.assertRaisesRegex(ValueError, 'held/tied tails'): with self.assertRaisesRegex(ValueError, 'held/tied tails'):
generator.fretboard.validate_document(source) generator.fretboard.validate_document(source)
with self.assertRaisesRegex(ValueError, 'no guitar placement'): with self.assertRaisesRegex(ValueError, 'no guitar placement'):
@@ -175,12 +174,12 @@ class FretPolicyTests(unittest.TestCase):
a['tie_to'], b['tie_from'] = b['id'], a['id'] a['tie_to'], b['tie_from'] = b['id'], a['id']
source['staves'][0]['measures'][0]['voices'][0]['notes'] = split source['staves'][0]['measures'][0]['voices'][0]['notes'] = split
generator.guitar.place_guitar(source) generator.guitar.place_guitar(source)
generator.model.validate_document(source) generator.validation.validate_document(source)
generator.fretboard.validate_document(source) generator.fretboard.validate_document(source)
self.assertEqual([p['tablature'] for p in split[0]['pitches']], [p['tablature'] for p in split[1]['pitches']]) self.assertEqual([p['tablature'] for p in split[0]['pitches']], [p['tablature'] for p in split[1]['pitches']])
split[0]['pitches'][0].pop('tie_to') split[0]['pitches'][0].pop('tie_to')
split[1]['pitches'].pop(0) split[1]['pitches'].pop(0)
generator.model.validate_document(source) generator.validation.validate_document(source)
with self.assertRaisesRegex(ValueError, 'held/tied tails'): with self.assertRaisesRegex(ValueError, 'held/tied tails'):
generator.fretboard.validate_document(source) generator.fretboard.validate_document(source)
def test_another_voice_cannot_supply_a_single_notes_anchor(self): def test_another_voice_cannot_supply_a_single_notes_anchor(self):
@@ -221,9 +220,9 @@ class FretPolicyTests(unittest.TestCase):
target = document(tuple(position(s, 12) for s in (3, 2, 1))) target = document(tuple(position(s, 12) for s in (3, 2, 1)))
reference, note = tests.test_musicxml.notes(source)[0], tests.test_musicxml.notes(target)[0] reference, note = tests.test_musicxml.notes(source)[0], tests.test_musicxml.notes(target)[0]
generator.electric.reuse_voicings([generator.electric.VoicingLink(note, (reference,))]) generator.electric.reuse_voicings([generator.electric.VoicingLink(note, (reference,))])
generator.model.validate_document(target) generator.validation.validate_document(target)
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
output = pathlib.Path(directory) / 'score.json' output = pathlib.Path(directory) / 'score.json'
with self.assertRaisesRegex(ValueError, 'each sounding chord requires an anchor'): with self.assertRaisesRegex(ValueError, 'each sounding chord requires an anchor'):
generator.score.write_json(output, target) generator.score_io.write_json(output, target)
self.assertFalse(output.exists()) self.assertFalse(output.exists())
+64
View File
@@ -0,0 +1,64 @@
"""Dependency direction, cache coverage and explicit composition inputs."""
import ast
import json
import pathlib
import subprocess
import sys
import unittest
import unittest.mock
import generator.cache
import generator.composition
import generator.role_cache
import generator.source
import generator.vocabulary
import tests.solo_fixtures
ROOT = pathlib.Path(__file__).resolve().parents[1]
class GeneratorBoundaries(unittest.TestCase):
def test_generator_imports_are_acyclic(self):
files = {'.'.join(path.relative_to(ROOT).with_suffix('').parts): path for path in (ROOT / 'generator').rglob('*.py')}
graph = {name: set(generator.cache.imports(path.read_bytes())) & files.keys() for name, path in files.items()}
visited, active = set(), []
def visit(name):
self.assertNotIn(name, active, ' -> '.join([*active, name]))
if name in visited:
return
active.append(name)
for dependency in graph[name]:
visit(dependency)
active.pop()
visited.add(name)
for name in graph:
visit(name)
def test_composition_import_does_not_load_rendering_or_publication(self):
code = "import generator.composition, sys, json; print(json.dumps(sorted(sys.modules)))"
result = subprocess.run([sys.executable, '-c', code], cwd=ROOT, capture_output=True, text=True, check=True)
forbidden = ('PIL', 'fontTools', 'generator.engraving', 'generator.production', 'generator.score_io')
self.assertFalse([name for name in json.loads(result.stdout) if name.startswith(forbidden)])
def test_all_previously_missing_role_dependencies_change_the_fingerprint(self):
baseline = generator.role_cache.code_key()
read = pathlib.Path.read_bytes
for name in ('solo/policy.py', 'source.py', 'accompaniment.py'):
changed = ROOT / 'generator' / name
with self.subTest(dependency=name):
def contents(path):
return read(path) + (b'\n# changed implementation\n' if path == changed else b'')
with unittest.mock.patch.object(pathlib.Path, 'read_bytes', contents):
self.assertNotEqual(generator.role_cache.code_key(), baseline)
def test_context_owns_immutable_resources_and_survives_caller_mutation(self):
resources = generator.vocabulary.load()
context = generator.vocabulary.CompositionContext.create(resources, 'electric_accompaniment')
expected = context.role()['center']
resources['roles']['roles']['electric_accompaniment']['center'] = 999
self.assertEqual(context.role()['center'], expected)
with self.assertRaises(TypeError):
context.data('roles')['roles']['electric_accompaniment']['center'] = 999
self.assertNotEqual(generator.vocabulary.load()['roles']['roles']['electric_accompaniment']['center'], 999)
def test_role_generation_does_not_consult_ambient_policy(self):
source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=1, chords=('CM7',)))
with unittest.mock.patch('generator.vocabulary.current', side_effect=AssertionError('implicit policy dependency')):
for role in generator.composition_types.ROLES:
with self.subTest(role=role):
generator.composition.generate_role(source, role, ROOT / 'data', {'predict': True})
+9 -7
View File
@@ -1,3 +1,5 @@
import generator.production
import generator.validation
import tests.production_fixtures import tests.production_fixtures
import argparse import argparse
import copy import copy
@@ -74,7 +76,7 @@ class GuitarTests(unittest.TestCase):
self.assertEqual(document, before) self.assertEqual(document, before)
document['staves'][0]['tablature'] = True document['staves'][0]['tablature'] = True
with self.assertRaisesRegex(ValueError, 'invalid generated tablature'): with self.assertRaisesRegex(ValueError, 'invalid generated tablature'):
generator.model.validate_document(document) generator.validation.validate_document(document)
def test_theory_controls_omissions_and_bass(self): def test_theory_controls_omissions_and_bass(self):
for symbol in ('CM', 'G7b9', 'FM7#11/A', 'CmM7', 'Cdim7', 'C13', 'Csus', 'Cmadd9'): for symbol in ('CM', 'G7b9', 'FM7#11/A', 'CmM7', 'Cdim7', 'C13', 'Csus', 'Cmadd9'):
with self.subTest(symbol=symbol): with self.subTest(symbol=symbol):
@@ -135,7 +137,7 @@ class GuitarTests(unittest.TestCase):
before = copy.deepcopy(source) before = copy.deepcopy(source)
electric = tests.arrangement_fixtures.section_document(source, 'electric') electric = tests.arrangement_fixtures.section_document(source, 'electric')
vocal = tests.arrangement_fixtures.section_document(source, 'vocal') vocal = tests.arrangement_fixtures.section_document(source, 'vocal')
generator.model.validate_document(electric) generator.validation.validate_document(electric)
self.assertEqual(source, before) self.assertEqual(source, before)
self.assertEqual([(e['onset'], e['duration'], generator.model.fraction(e['pitch']) - 12) for e in tests.score_events.timeline(electric)['events']], [(e['onset'], e['duration'], generator.model.fraction(e['pitch'])) for e in tests.score_events.timeline(vocal)['events']]) self.assertEqual([(e['onset'], e['duration'], generator.model.fraction(e['pitch']) - 12) for e in tests.score_events.timeline(electric)['events']], [(e['onset'], e['duration'], generator.model.fraction(e['pitch'])) for e in tests.score_events.timeline(vocal)['events']])
self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in tests.score_events.timeline(electric)['events']], [(e['pitch'], e['onset'], e['duration']) for e in tests.score_events.timeline(source)['events']]) self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in tests.score_events.timeline(electric)['events']], [(e['pitch'], e['onset'], e['duration']) for e in tests.score_events.timeline(source)['events']])
@@ -156,7 +158,7 @@ class GuitarTests(unittest.TestCase):
self.assertEqual(generator.model.pitch_number(tone), 12 * (octave + 1) + alter) self.assertEqual(generator.model.pitch_number(tone), 12 * (octave + 1) + alter)
self.assertEqual(tone['tablature'], tab) self.assertEqual(tone['tablature'], tab)
self.assertEqual(generator.model.fraction(tests.score_events.timeline(document)['events'][0]['pitch']), expected) self.assertEqual(generator.model.fraction(tests.score_events.timeline(document)['events'][0]['pitch']), expected)
generator.model.validate_document(document) generator.validation.validate_document(document)
def test_guitar_normalization_is_idempotent_and_respects_timed_transposition(self): def test_guitar_normalization_is_idempotent_and_respects_timed_transposition(self):
change = '<attributes><transpose><chromatic>0</chromatic></transpose></attributes>' change = '<attributes><transpose><chromatic>0</chromatic></transpose></attributes>'
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + change + tests.test_musicxml.xml_note(duration=2, kind='half'))) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + change + tests.test_musicxml.xml_note(duration=2, kind='half')))
@@ -173,7 +175,7 @@ class GuitarTests(unittest.TestCase):
for part in generator.parts.PART_TYPES: for part in generator.parts.PART_TYPES:
with self.subTest(generator=generate.__name__, part=part): with self.subTest(generator=generate.__name__, part=part):
document = generate(source, part) document = generate(source, part)
generator.model.validate_document(document) generator.validation.validate_document(document)
written = {p['id']: generator.model.pitch_number(p) for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']} written = {p['id']: generator.model.pitch_number(p) for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']}
events = tests.score_events.timeline(document)['events'] events = tests.score_events.timeline(document)['events']
self.assertTrue(events) self.assertTrue(events)
@@ -231,7 +233,7 @@ class GuitarTests(unittest.TestCase):
chord['pitches'] = [] chord['pitches'] = []
chord['_harmony'] = generator.harmony.parse_symbol('CM') chord['_harmony'] = generator.harmony.parse_symbol('CM')
generator.guitar.place_guitar(document) generator.guitar.place_guitar(document)
generator.model.validate_document(document) generator.validation.validate_document(document)
self.assertEqual(melody['pitches'][0]['tablature'], {'string': 1, 'fret': 12}) self.assertEqual(melody['pitches'][0]['tablature'], {'string': 1, 'fret': 12})
self.assertNotIn(1, [p['tablature']['string'] for p in chord['pitches']]) self.assertNotIn(1, [p['tablature']['string'] for p in chord['pitches']])
self.assertEqual(len(tests.score_events.timeline(document)['events']), 3) self.assertEqual(len(tests.score_events.timeline(document)['events']), 3)
@@ -256,9 +258,9 @@ class GuitarTests(unittest.TestCase):
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output' source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
source.mkdir() source.mkdir()
(source / '0000.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>E</root-step><root-alter>-1</root-alter></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>')) (source / '0000.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>E</root-step><root-alter>-1</root-alter></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output), 3) self.assertEqual(generator.production.generate_tree(source, output), 3)
first = {p.name: p.read_bytes() for p in output.rglob('*.json')} first = {p.name: p.read_bytes() for p in output.rglob('*.json')}
generator.arrange.generate.generate_performance_scores(source, output) generator.production.generate_tree(source, output)
self.assertEqual(first, {p.name: p.read_bytes() for p in output.rglob('*.json')}) self.assertEqual(first, {p.name: p.read_bytes() for p in output.rglob('*.json')})
def test_unplayable_generation_preserves_previous_assets(self): def test_unplayable_generation_preserves_previous_assets(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
+5 -6
View File
@@ -1,15 +1,14 @@
import generator.score
import generator.validation
import copy import copy
import fractions import fractions
import unittest import unittest
import generator.accompaniment
import generator.acoustic_voicing import generator.acoustic_voicing
import generator.arrange.generate
import generator.fretboard import generator.fretboard
import generator.guitar import generator.guitar
import generator.harmony import generator.harmony
import generator.model import generator.model
import generator.parts
import tests.score_events import tests.score_events
import tests.arrangement_fixtures import tests.arrangement_fixtures
import tests.test_accompaniment import tests.test_accompaniment
@@ -20,7 +19,7 @@ class GuitarPartTests(unittest.TestCase):
def section(self, content, part='electric', divisions=1): def section(self, content, part='electric', divisions=1):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=divisions)) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=divisions))
result = tests.arrangement_fixtures.section_document(source, part) result = tests.arrangement_fixtures.section_document(source, part)
generator.model.validate_document(result) generator.validation.validate_document(result)
return result return result
def test_profile_shapes_cover_defining_chord_tones(self): def test_profile_shapes_cover_defining_chord_tones(self):
for symbol in ('CM', 'Cm', 'C7', 'CmM7', 'Csus', 'Cdim7', 'Caug7', 'C7b9', 'C13', 'CM/E'): for symbol in ('CM', 'Cm', 'C7', 'CmM7', 'Csus', 'Cdim7', 'Caug7', 'C7b9', 'C13', 'CM/E'):
@@ -50,7 +49,7 @@ class GuitarPartTests(unittest.TestCase):
self.assertTrue(all({p.midi % 12 for p in c} == {0, 7} for c in candidates)) self.assertTrue(all({p.midi % 12 for p in c} == {0, 7} for c in candidates))
def test_acoustic_uses_bass_and_three_upper_notes(self): def test_acoustic_uses_bass_and_three_upper_notes(self):
result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic') result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
generator.model.validate_document(result) generator.validation.validate_document(result)
generator.fretboard.validate_document(result) generator.fretboard.validate_document(result)
sounding = [n for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) if n['pitches']] sounding = [n for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) if n['pitches']]
self.assertTrue(sounding) self.assertTrue(sounding)
@@ -99,7 +98,7 @@ class GuitarPartTests(unittest.TestCase):
second = tests.test_parts.xml_harmony(root) + tests.test_musicxml.xml_note('E', octave=4, extra='<tie type="stop"/>') second = tests.test_parts.xml_harmony(root) + tests.test_musicxml.xml_note('E', octave=4, extra='<tie type="stop"/>')
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([first, second])) source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([first, second]))
result = tests.arrangement_fixtures.section_document(source, 'electric') result = tests.arrangement_fixtures.section_document(source, 'electric')
generator.model.validate_document(result) generator.validation.validate_document(result)
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes']] notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes']]
self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes)) self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes))
self.assertEqual(bool(notes[0]['pitches'][0].get('tie_to')), root == 'C') self.assertEqual(bool(notes[0]['pitches'][0].get('tie_to')), root == 'C')
+2 -4
View File
@@ -7,15 +7,13 @@ import unittest
import generator.train import generator.train
import generator.training_io import generator.training_io
import generator.vocabulary
import generator.harmonic_time import generator.harmonic_time
import generator.harmony import generator.harmony
import generator.solo.harmonic import generator.solo.harmonic
import generator.solo.types import generator.solo.types
import generator.solo.decoder import generator.solo.decoder
import generator.solo.learned import generator.solo.learned
import generator.solo.analysis import generator.source
import generator.solo.expressions
import tests.solo_fixtures import tests.solo_fixtures
import generator.musicxml import generator.musicxml
import generator.parts import generator.parts
@@ -128,7 +126,7 @@ class ContextualHarmony(unittest.TestCase):
class PredictionReachability(unittest.TestCase): class PredictionReachability(unittest.TestCase):
def engine(self, chords): def engine(self, chords):
doc = tests.solo_fixtures.song(count=2, chords=chords) doc = tests.solo_fixtures.song(count=2, chords=chords)
return generator.solo.decoder.Decoder(generator.solo.analysis.analyze(doc), generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(chords)), generator.solo.learned.Model.load(ROOT / 'data/models/solo.json'), 'acoustic', fractions.Fraction(), fractions.Fraction(8)) return generator.solo.decoder.Decoder(generator.source.analyze(doc), generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(chords)), generator.solo.learned.Model.load(ROOT / 'data/models/solo.json'), 'acoustic', fractions.Fraction(), fractions.Fraction(8))
def test_joint_resolved_color_reaches_the_real_action_builder(self): def test_joint_resolved_color_reaches_the_real_action_builder(self):
d = self.engine(('C7', 'FM7')) d = self.engine(('C7', 'FM7'))
n = d.note(fractions.Fraction(), fractions.Fraction(3, 2), 66) n = d.note(fractions.Fraction(), fractions.Fraction(3, 2), 66)
-1
View File
@@ -6,7 +6,6 @@ import generator.engraving.fonts
import generator.engraving.layout import generator.engraving.layout
import generator.engraving.types import generator.engraving.types
import generator.harmony import generator.harmony
import generator.model
import generator.musicxml import generator.musicxml
import generator.musicxml_writer import generator.musicxml_writer
import generator.pitch import generator.pitch
+27 -27
View File
@@ -1,4 +1,9 @@
"""Dependency and music contracts use synthetic sources, never audition scores.""" """Dependency and music contracts use synthetic sources, never audition scores."""
import generator.composition_types
import generator.arrange.assembly
import generator.composition
import generator.source
import generator.validation
import concurrent.futures import concurrent.futures
import copy import copy
import contextlib import contextlib
@@ -13,15 +18,10 @@ import unittest.mock
import generator.production import generator.production
import generator.model import generator.model
import generator.vocabulary
import generator.solo.expressions
import generator.solo.types import generator.solo.types
import generator.solo.harmonic import generator.solo.harmonic
import generator.solo.placement import generator.solo.placement
import generator.solo.notation import generator.solo.notation
import generator.solo.composer
import tests.solo_fixtures
import tests.solo_fixtures
import tests.solo_fixtures import tests.solo_fixtures
def music(result): def music(result):
@@ -30,23 +30,23 @@ def music(result):
class IndependentRoles(unittest.TestCase): class IndependentRoles(unittest.TestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=1, chords=('CM7',))) cls.source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=1, chords=('CM7',)))
cls.root = pathlib.Path('data').resolve() cls.root = pathlib.Path('data').resolve()
def test_alone_order_parallel_and_assembly(self): def test_alone_order_parallel_and_assembly(self):
serial = {role: generator.production.generate_role(self.source, role, self.root) for role in generator.production.ROLES} serial = {role: generator.composition.generate_role(self.source, role, self.root) for role in generator.composition_types.ROLES}
with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool: with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool:
jobs = {role: pool.submit(generator.production._job, (self.source, role, self.root, {}, None)) for role in reversed(generator.production.ROLES)} jobs = {role: pool.submit(generator.production._job, generator.composition_types.RoleRequest(self.source, role, self.root)) for role in reversed(generator.composition_types.ROLES)}
parallel = {role: future.result() for role, future in jobs.items()} parallel = {role: future.result() for role, future in jobs.items()}
for role in generator.production.ROLES: for role in generator.composition_types.ROLES:
self.assertEqual(music(serial[role]), music(parallel[role]), role) self.assertEqual(music(serial[role]), music(parallel[role]), role)
for doc in generator.production.assemble(self.source, serial).values(): for doc in generator.arrange.assembly.assemble(self.source, serial).values():
generator.model.validate_document(doc) generator.validation.validate_document(doc)
def test_role_setting_and_cache_are_independent(self): def test_role_setting_and_cache_are_independent(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
a = generator.production.generate_role(self.source, 'acoustic_solo', self.root, cache_dir=directory) a = generator.composition.generate_role(self.source, 'acoustic_solo', self.root, cache_dir=directory)
b = generator.production.generate_role(self.source, 'electric_solo', self.root, {'seed': 'changed'}, directory) b = generator.composition.generate_role(self.source, 'electric_solo', self.root, {'seed': 'changed'}, directory)
again = generator.production.generate_role(self.source, 'acoustic_solo', self.root, cache_dir=directory) again = generator.composition.generate_role(self.source, 'acoustic_solo', self.root, cache_dir=directory)
clean = generator.production.generate_role(self.source, 'acoustic_solo', self.root) clean = generator.composition.generate_role(self.source, 'acoustic_solo', self.root)
self.assertTrue(again['cache_hit']) self.assertTrue(again['cache_hit'])
self.assertEqual(music(a), music(again)) self.assertEqual(music(a), music(again))
self.assertEqual(music(a), music(clean)) self.assertEqual(music(a), music(clean))
@@ -60,7 +60,7 @@ class IndependentRoles(unittest.TestCase):
self.assertNotIn('generated_assets', str(path)) self.assertNotIn('generated_assets', str(path))
return original(path, *args, **kwargs) return original(path, *args, **kwargs)
with unittest.mock.patch.object(pathlib.Path, 'open', guarded): with unittest.mock.patch.object(pathlib.Path, 'open', guarded):
result = generator.production.generate_role(self.source, 'acoustic_solo', self.root) result = generator.composition.generate_role(self.source, 'acoustic_solo', self.root)
self.assertNotIn('reference', json.dumps(result['trace'])) self.assertNotIn('reference', json.dumps(result['trace']))
def test_titles_and_export_ids_do_not_supply_musical_identity(self): def test_titles_and_export_ids_do_not_supply_musical_identity(self):
document = tests.solo_fixtures.song(count=1, chords=('CM7',)) document = tests.solo_fixtures.song(count=1, chords=('CM7',))
@@ -69,7 +69,7 @@ class IndependentRoles(unittest.TestCase):
for m in other['staves'][0]['measures']: for m in other['staves'][0]['measures']:
m['id'] = 'exporter_123' m['id'] = 'exporter_123'
m['number'] = '99' m['number'] = '99'
self.assertEqual(generator.production.SourceAnalysis.read(document), generator.production.SourceAnalysis.read(other)) self.assertEqual(generator.source.SourceAnalysis.read(document), generator.source.SourceAnalysis.read(other))
def test_reference_presence_and_changes_do_not_change_clean_music(self): def test_reference_presence_and_changes_do_not_change_clean_music(self):
# No repository reference is a fixture or prerequisite. These deliberately # No repository reference is a fixture or prerequisite. These deliberately
# invalid optional files test the absence of a discovery/fallback branch. # invalid optional files test the absence of a discovery/fallback branch.
@@ -78,16 +78,16 @@ class IndependentRoles(unittest.TestCase):
reference_dir.mkdir() reference_dir.mkdir()
references = [reference_dir / (source_name + '-solo-' + str(index) + suffix + '.xml') references = [reference_dir / (source_name + '-solo-' + str(index) + suffix + '.xml')
for source_name in ('all-of-me', 'autumn-leaves') for index in (1, 2) for suffix in ('', '(4)')] for source_name in ('all-of-me', 'autumn-leaves') for index in (1, 2) for suffix in ('', '(4)')]
before = generator.production.generate_role(self.source, 'electric_solo', self.root) before = generator.composition.generate_role(self.source, 'electric_solo', self.root)
for reference in references: for reference in references:
reference.write_text('<not-a-production-source/>') reference.write_text('<not-a-production-source/>')
present = generator.production.generate_role(self.source, 'electric_solo', self.root) present = generator.composition.generate_role(self.source, 'electric_solo', self.root)
for reference in references: for reference in references:
reference.write_text('changed optional target, still not MusicXML') reference.write_text('changed optional target, still not MusicXML')
changed = generator.production.generate_role(self.source, 'electric_solo', self.root) changed = generator.composition.generate_role(self.source, 'electric_solo', self.root)
for reference in references: for reference in references:
reference.unlink() reference.unlink()
absent = generator.production.generate_role(self.source, 'electric_solo', self.root) absent = generator.composition.generate_role(self.source, 'electric_solo', self.root)
self.assertTrue(all(music(before) == music(v) for v in (present, changed, absent))) self.assertTrue(all(music(before) == music(v) for v in (present, changed, absent)))
def test_original_melody_changes_the_theme_with_unchanged_harmony(self): def test_original_melody_changes_the_theme_with_unchanged_harmony(self):
source = tests.solo_fixtures.song(count=4, chords=('CM7',)) source = tests.solo_fixtures.song(count=4, chords=('CM7',))
@@ -97,11 +97,11 @@ class IndependentRoles(unittest.TestCase):
for n in v['notes']: for n in v['notes']:
for p in n['pitches']: for p in n['pitches']:
p['letter'] = {'c': 'e', 'd': 'f', 'e': 'g', 'f': 'a'}[p['letter']] p['letter'] = {'c': 'e', 'd': 'f', 'e': 'g', 'f': 'a'}[p['letter']]
a = generator.production.generate_role(generator.production.SourceAnalysis.read(source), 'acoustic_solo', self.root) a = generator.composition.generate_role(generator.source.SourceAnalysis.read(source), 'acoustic_solo', self.root)
b = generator.production.generate_role(generator.production.SourceAnalysis.read(changed), 'acoustic_solo', self.root) b = generator.composition.generate_role(generator.source.SourceAnalysis.read(changed), 'acoustic_solo', self.root)
self.assertNotEqual(a['details']['phrases'][0]['notes'], b['details']['phrases'][0]['notes']) self.assertNotEqual(a['details']['phrases'][0]['notes'], b['details']['phrases'][0]['notes'])
def test_source_tuplet_lanes_survive_assembly(self): def test_source_tuplet_lanes_survive_assembly(self):
serial = {r: generator.production.generate_role(self.source, r, self.root) for r in generator.production.ROLES} serial = {r: generator.composition.generate_role(self.source, r, self.root) for r in generator.composition_types.ROLES}
# Exercise a different original lane name, as in the imported Autumn head. # Exercise a different original lane name, as in the imported Autumn head.
doc = serial['electric_melody']['document'] doc = serial['electric_melody']['document']
staff = doc['staves'][0] staff = doc['staves'][0]
@@ -117,10 +117,10 @@ class IndependentRoles(unittest.TestCase):
n['voice'] = 'voice_source' n['voice'] = 'voice_source'
for t in staff['tuplets']: for t in staff['tuplets']:
t['voice'] = 'voice_source' t['voice'] = 'voice_source'
generator.model.validate_document(doc) generator.validation.validate_document(doc)
generator.model.validate_document(generator.production.assemble(self.source, serial)['electric']) generator.validation.validate_document(generator.arrange.assembly.assemble(self.source, serial)['electric'])
def test_sparse_parts_reuse_the_canonical_form(self): def test_sparse_parts_reuse_the_canonical_form(self):
result = generator.production.generate_role(generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=4, chords=('Dm7', 'G7', 'CM7'))), 'acoustic_accompaniment', self.root) result = generator.composition.generate_role(generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=4, chords=('Dm7', 'G7', 'CM7'))), 'acoustic_accompaniment', self.root)
measures = result['document']['staves'][0]['measures'] measures = result['document']['staves'][0]['measures']
def signature(m): def signature(m):
return [(n['onset'], n['duration'], [(generator.model.pitch_number(p), p['tablature']) for p in n['pitches']]) for v in m['voices'] for n in v['notes']] return [(n['onset'], n['duration'], [(generator.model.pitch_number(p), p['tablature']) for p in n['pitches']]) for v in m['voices'] for n in v['notes']]
+2 -1
View File
@@ -1,3 +1,4 @@
import generator.validation
import copy import copy
import fractions import fractions
import pathlib import pathlib
@@ -43,7 +44,7 @@ class KeyCancellationTests(unittest.TestCase):
source = key_change(old, new, local) source = key_change(old, new, local)
before = copy.deepcopy(source) before = copy.deepcopy(source)
result = instrument_score(source, *({'A': (1, 2), 'Ab': (-1, -2), 'G': (1, 2), 'F': (-1, -2)}[target])) result = instrument_score(source, *({'A': (1, 2), 'Ab': (-1, -2), 'G': (1, 2), 'F': (-1, -2)}[target]))
generator.model.validate_document(result) generator.validation.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
self.assertEqual(measures[0]['key'], generator.model.key_from_fifths(expected_old)) self.assertEqual(measures[0]['key'], generator.model.key_from_fifths(expected_old))
change = measures[0]['attributes'][0] if local else measures[1] change = measures[0]['attributes'][0] if local else measures[1]
+12 -12
View File
@@ -1,4 +1,5 @@
"""Learned inference and explicit authored reuse have different contracts.""" """Learned inference and explicit authored reuse have different contracts."""
import generator.composition
import copy import copy
import dataclasses import dataclasses
import json import json
@@ -8,7 +9,6 @@ import tempfile
import unittest import unittest
import unittest.mock import unittest.mock
import generator.production
import generator.parts import generator.parts
import generator.musicxml import generator.musicxml
import generator.model import generator.model
@@ -21,7 +21,7 @@ import generator.solo.learned
import generator.solo.composer import generator.solo.composer
import generator.solo.decoder import generator.solo.decoder
import generator.solo.harmonic import generator.solo.harmonic
import generator.solo.analysis import generator.source
import tests.score_events import tests.score_events
import tests.solo_fixtures import tests.solo_fixtures
import tests.test_musicxml import tests.test_musicxml
@@ -53,9 +53,9 @@ class TrainingAndRepertoire(unittest.TestCase):
with self.subTest(pair=entry['id']): with self.subTest(pair=entry['id']):
part = 'acoustic' if entry['slot'] == 1 else 'electric' part = 'acoustic' if entry['slot'] == 1 else 'electric'
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source'])) doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
source = generator.production.SourceAnalysis.read(doc) source = generator.source.SourceAnalysis.read(doc)
with unittest.mock.patch('generator.solo.composer.compose', side_effect=AssertionError('authored pair must not be predicted')): with unittest.mock.patch('generator.solo.composer.compose', side_effect=AssertionError('authored pair must not be predicted')):
result = generator.production.generate_role(source, part + '_solo', ROOT / 'data') result = generator.composition.generate_role(source, part + '_solo', ROOT / 'data')
measures = result['document']['staves'][0]['measures'] measures = result['document']['staves'][0]['measures']
first = next(i for i, m in enumerate(measures) if m.get('section_label') == part.capitalize() + ' Solo') first = next(i for i, m in enumerate(measures) if m.get('section_label') == part.capitalize() + ' Solo')
last = next((i for i in range(first + 1, len(measures)) if measures[i].get('section_label')), len(measures)) last = next((i for i in range(first + 1, len(measures)) if measures[i].get('section_label')), len(measures))
@@ -82,13 +82,13 @@ class TrainingAndRepertoire(unittest.TestCase):
def test_lookup_uses_source_music_and_slot_not_title_or_path(self): def test_lookup_uses_source_music_and_slot_not_title_or_path(self):
entry = json.loads((ROOT / 'data/training/manifest.json').read_text())['examples'][0] entry = json.loads((ROOT / 'data/training/manifest.json').read_text())['examples'][0]
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source'])) doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
source = generator.production.SourceAnalysis.read(doc) source = generator.source.SourceAnalysis.read(doc)
part = 'acoustic' if entry['slot'] == 1 else 'electric' part = 'acoustic' if entry['slot'] == 1 else 'electric'
self.assertIsNotNone(generator.solo.authored.lookup(source, part, ROOT / 'data')) self.assertIsNotNone(generator.solo.authored.lookup(source, part, ROOT / 'data'))
doc['metadata'].update(title='Renamed standard', composer='Display only') doc['metadata'].update(title='Renamed standard', composer='Display only')
self.assertEqual(source, generator.production.SourceAnalysis.read(doc)) self.assertEqual(source, generator.source.SourceAnalysis.read(doc))
doc['staves'][0]['measures'][0]['voices'][0]['notes'][0]['pitches'][0]['octave'] += 1 doc['staves'][0]['measures'][0]['voices'][0]['notes'][0]['pitches'][0]['octave'] += 1
self.assertIsNone(generator.solo.authored.lookup(generator.production.SourceAnalysis.read(doc), part, ROOT / 'data')) self.assertIsNone(generator.solo.authored.lookup(generator.source.SourceAnalysis.read(doc), part, ROOT / 'data'))
def test_adding_a_new_pair_requires_no_code_or_existing_song_names(self): def test_adding_a_new_pair_requires_no_code_or_existing_song_names(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory) root = pathlib.Path(directory)
@@ -102,7 +102,7 @@ class TrainingAndRepertoire(unittest.TestCase):
examples, _ = generator.train.load_examples(manifest) examples, _ = generator.train.load_examples(manifest)
self.assertEqual(len(examples), 1) self.assertEqual(len(examples), 1)
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(root / 'data/songs' / source.name)) doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(root / 'data/songs' / source.name))
context = generator.production.SourceAnalysis.read(doc) context = generator.source.SourceAnalysis.read(doc)
self.assertIsNotNone(generator.solo.authored.lookup(context, 'acoustic', root / 'data')) self.assertIsNotNone(generator.solo.authored.lookup(context, 'acoustic', root / 'data'))
self.assertIsNone(generator.solo.authored.lookup(context, 'electric', root / 'data')) self.assertIsNone(generator.solo.authored.lookup(context, 'electric', root / 'data'))
self.assertEqual(solo.read_bytes(), (manifest.parent / solo.name).read_bytes()) self.assertEqual(solo.read_bytes(), (manifest.parent / solo.name).read_bytes())
@@ -130,7 +130,7 @@ class LearnedInference(unittest.TestCase):
generator.solo.learned.Model.load('/missing-model-file') generator.solo.learned.Model.load('/missing-model-file')
def test_decoder_runs_without_training_files_and_is_deterministic(self): def test_decoder_runs_without_training_files_and_is_deterministic(self):
source = tests.solo_fixtures.song(count=1, chords=('C7',)) source = tests.solo_fixtures.song(count=1, chords=('C7',))
a = generator.solo.analysis.analyze(source) a = generator.source.analyze(source)
predictor = generator.solo.learned.Model.load(ROOT / 'data/models/solo.json') predictor = generator.solo.learned.Model.load(ROOT / 'data/models/solo.json')
with generator.vocabulary.using(generator.vocabulary.load(), 'acoustic_solo', {'attempts': 2}): with generator.vocabulary.using(generator.vocabulary.load(), 'acoustic_solo', {'attempts': 2}):
original = pathlib.Path.open original = pathlib.Path.open
@@ -146,7 +146,7 @@ class LearnedInference(unittest.TestCase):
self.assertEqual(first.diagnostics['construction'], 'learned_joint_events') self.assertEqual(first.diagnostics['construction'], 'learned_joint_events')
def test_event_score_does_not_depend_on_action_grouping(self): def test_event_score_does_not_depend_on_action_grouping(self):
source = tests.solo_fixtures.song(count=1, chords=('C7',)) source = tests.solo_fixtures.song(count=1, chords=('C7',))
a = generator.solo.analysis.analyze(source) a = generator.source.analyze(source)
h = generator.solo.harmonic.Harmony(generator.harmonic_time.read(source['staves'][0])) h = generator.solo.harmonic.Harmony(generator.harmonic_time.read(source['staves'][0]))
predictor = generator.solo.learned.Model.load(ROOT / 'data/models/solo.json') predictor = generator.solo.learned.Model.load(ROOT / 'data/models/solo.json')
engine = generator.solo.decoder.Decoder(a, h, predictor, 'acoustic', fractions.Fraction(), fractions.Fraction(4)) engine = generator.solo.decoder.Decoder(a, h, predictor, 'acoustic', fractions.Fraction(), fractions.Fraction(4))
@@ -157,7 +157,7 @@ class LearnedInference(unittest.TestCase):
self.assertEqual(combined, split) self.assertEqual(combined, split)
def test_source_features_include_actual_melodic_intervals(self): def test_source_features_include_actual_melodic_intervals(self):
source = tests.solo_fixtures.song(count=1, chords=('C7',)) source = tests.solo_fixtures.song(count=1, chords=('C7',))
a = generator.solo.analysis.analyze(source) a = generator.source.analyze(source)
changed = dataclasses.replace(a, melody=tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave + (i == 1))) for i, n in enumerate(a.melody))) changed = dataclasses.replace(a, melody=tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave + (i == 1))) for i, n in enumerate(a.melody)))
self.assertNotEqual(generator.solo.learned.source_feature(a, fractions.Fraction(1)), generator.solo.learned.source_feature(changed, fractions.Fraction(1))) self.assertNotEqual(generator.solo.learned.source_feature(a, fractions.Fraction(1)), generator.solo.learned.source_feature(changed, fractions.Fraction(1)))
def test_generated_long_nonchord_notes_still_need_support(self): def test_generated_long_nonchord_notes_still_need_support(self):
@@ -167,7 +167,7 @@ class LearnedInference(unittest.TestCase):
self.assertTrue(h.evaluate((tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'g'), tests.solo_fixtures.note(fractions.Fraction(1, 2), 1, 'f'), tests.solo_fixtures.note(1, 2, 'e'))).valid) self.assertTrue(h.evaluate((tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'g'), tests.solo_fixtures.note(fractions.Fraction(1, 2), 1, 'f'), tests.solo_fixtures.note(1, 2, 'e'))).valid)
def test_held_chord_member_can_become_a_supported_color(self): def test_held_chord_member_can_become_a_supported_color(self):
source = tests.solo_fixtures.song(count=2, chords=('C', 'Dm')) source = tests.solo_fixtures.song(count=2, chords=('C', 'Dm'))
a = generator.solo.analysis.analyze(source) a = generator.source.analyze(source)
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C', 'Dm'))) h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C', 'Dm')))
engine = generator.solo.decoder.Decoder(a, h, generator.solo.learned.Model.load(ROOT / 'data/models/solo.json'), 'acoustic', fractions.Fraction(), fractions.Fraction(8)) engine = generator.solo.decoder.Decoder(a, h, generator.solo.learned.Model.load(ROOT / 'data/models/solo.json'), 'acoustic', fractions.Fraction(), fractions.Fraction(8))
n = engine.note(fractions.Fraction(3), fractions.Fraction(6), 64) # E: C's third becomes D minor's ninth. n = engine.note(fractions.Fraction(3), fractions.Fraction(6), 64) # E: C's third becomes D minor's ninth.
+7 -6
View File
@@ -1,3 +1,4 @@
import generator.validation
import tests.production_fixtures import tests.production_fixtures
import argparse import argparse
import fractions import fractions
@@ -73,16 +74,16 @@ class MusicModelTests(unittest.TestCase):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
tests.test_musicxml.notes(document)[0]['pitches'][0]['tie_to'] = 'missing' tests.test_musicxml.notes(document)[0]['pitches'][0]['tie_to'] = 'missing'
with self.assertRaisesRegex(ValueError, 'dangling tie'): with self.assertRaisesRegex(ValueError, 'dangling tie'):
generator.model.validate_document(document) generator.validation.validate_document(document)
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
tests.test_musicxml.notes(document)[0]['duration'] = {'numerator': 8, 'denominator': 2} tests.test_musicxml.notes(document)[0]['duration'] = {'numerator': 8, 'denominator': 2}
with self.assertRaisesRegex(ValueError, 'reduced'): with self.assertRaisesRegex(ValueError, 'reduced'):
generator.model.validate_document(document) generator.validation.validate_document(document)
def test_optional_turnaround_and_form(self): def test_optional_turnaround_and_form(self):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/all-of-me.xml')) source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/all-of-me.xml'))
for part in ('acoustic', 'electric', 'vocal'): for part in ('acoustic', 'electric', 'vocal'):
result = generator.arrange.generate.arrange_document(source, part) result = generator.arrange.generate.arrange_document(source, part)
generator.model.validate_document(result) generator.validation.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody', 'Ending']) self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody', 'Ending'])
self.assertFalse(measures[-1]['harmonies']) self.assertFalse(measures[-1]['harmonies'])
@@ -96,12 +97,12 @@ class MusicModelTests(unittest.TestCase):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
source['staves'][0]['measures'][0][field] = {} source['staves'][0]['measures'][0][field] = {}
with self.assertRaisesRegex(ValueError, 'repeat notation'): with self.assertRaisesRegex(ValueError, 'repeat notation'):
generator.model.validate_document(source) generator.validation.validate_document(source)
for field in ('sound_ties', 'printed_ties'): for field in ('sound_ties', 'printed_ties'):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
tests.test_musicxml.notes(source)[0]['pitches'][0][field] = [] tests.test_musicxml.notes(source)[0]['pitches'][0][field] = []
with self.assertRaisesRegex(ValueError, 'pass-specific tie'): with self.assertRaisesRegex(ValueError, 'pass-specific tie'):
generator.model.validate_document(source) generator.validation.validate_document(source)
def test_failure_preserves_generated_assets(self): def test_failure_preserves_generated_assets(self):
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
root = pathlib.Path(temp) root = pathlib.Path(temp)
@@ -149,7 +150,7 @@ class MusicModelTests(unittest.TestCase):
source = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), implicit=True) source = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), implicit=True)
source = source.replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/>') + '</measure></part>') source = source.replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/>') + '</measure></part>')
result = generator.arrange.generate.arrange_document(tests.test_musicxml.parse(source), 'vocal') result = generator.arrange.generate.arrange_document(tests.test_musicxml.parse(source), 'vocal')
generator.model.validate_document(result) generator.validation.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
start = measures[0]['voices'][0]['notes'][-1]['pitches'][0] start = measures[0]['voices'][0]['notes'][-1]['pitches'][0]
end = measures[1]['voices'][0]['notes'][0]['pitches'][0] end = measures[1]['voices'][0]['notes'][0]['pitches'][0]
+20
View File
@@ -0,0 +1,20 @@
"""Exact role scores protect musical choices, notation, IDs and tablature."""
import generator.composition_types
import generator.composition
import generator.source
import json
import pathlib
import unittest
import tests.solo_fixtures
class OutputContract(unittest.TestCase):
def test_complete_roles_including_learned_fallback_match_reviewed_output(self):
root = pathlib.Path(__file__).resolve().parents[1]
expected = json.loads((root / 'tests/fixtures/role_outputs.json').read_text())
source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=2, chords=('Dm7', 'G7')))
for role in generator.composition_types.ROLES:
with self.subTest(role=role):
settings = {'predict': True} if role.endswith('_solo') else {}
actual = generator.composition.generate_role(source, role, root / 'data', settings)['document']
self.assertEqual(actual, expected[role])
+9 -8
View File
@@ -1,3 +1,6 @@
import generator.production
import generator.score
import generator.validation
import tests.production_fixtures import tests.production_fixtures
import argparse import argparse
import copy import copy
@@ -10,11 +13,9 @@ import unittest.mock
import xml.etree.ElementTree import xml.etree.ElementTree
import generator.__main__ import generator.__main__
import generator.accompaniment
import generator.arrange.generate import generator.arrange.generate
import generator.model import generator.model
import generator.musicxml import generator.musicxml
import generator.parts
import generator.pitch import generator.pitch
import tests.score_events import tests.score_events
import tests.arrangement_fixtures import tests.arrangement_fixtures
@@ -37,7 +38,7 @@ class PartGenerationTests(unittest.TestCase):
def test_dense_melody_keeps_rhythmic_accompaniment_without_sustaining_across_changes(self): def test_dense_melody_keeps_rhythmic_accompaniment_without_sustaining_across_changes(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(xml_harmony() + xml_harmony('F', offset=3) + ''.join(tests.test_musicxml.xml_note(duration=1, kind='eighth') for _ in range(8)), divisions=2)) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(xml_harmony() + xml_harmony('F', offset=3) + ''.join(tests.test_musicxml.xml_note(duration=1, kind='eighth') for _ in range(8)), divisions=2))
for result in (tests.arrangement_fixtures.section_document(source, 'acoustic'), generator.arrange.generate.arrange_document(source, 'electric')): for result in (tests.arrangement_fixtures.section_document(source, 'acoustic'), generator.arrange.generate.arrange_document(source, 'electric')):
generator.model.validate_document(result) generator.validation.validate_document(result)
for measure in result['staves'][0]['measures']: for measure in result['staves'][0]['measures']:
events = measure['voices'][0]['notes'] events = measure['voices'][0]['notes']
if measure.get('ending_sustain'): if measure.get('ending_sustain'):
@@ -72,13 +73,13 @@ class PartGenerationTests(unittest.TestCase):
self.assertTrue(all(generator.model.pitch_number(p) % 12 in {0, 5, 9} for n in carried for p in n['pitches'])) self.assertTrue(all(generator.model.pitch_number(p) % 12 in {0, 5, 9} for n in carried for p in n['pitches']))
for index in (2, 3): for index in (2, 3):
self.assertTrue(all(n['kind'] == 'rest' and not n['pitches'] for n in tests.test_musicxml.notes(result, index))) self.assertTrue(all(n['kind'] == 'rest' and not n['pitches'] for n in tests.test_musicxml.notes(result, index)))
generator.model.validate_document(result) generator.validation.validate_document(result)
def test_sparse_accompaniment_preserves_form_and_intentional_space(self): def test_sparse_accompaniment_preserves_form_and_intentional_space(self):
bar = xml_harmony() + ''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter') for _ in range(4)) bar = xml_harmony() + ''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter') for _ in range(4))
xml = tests.test_musicxml.xml_score(bar).replace('</part>', ''.join(f'<measure number="{i}">{bar}</measure>' for i in range(2, 13)) + '</part>') xml = tests.test_musicxml.xml_score(bar).replace('</part>', ''.join(f'<measure number="{i}">{bar}</measure>' for i in range(2, 13)) + '</part>')
result = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(xml), 'acoustic') result = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(xml), 'acoustic')
self.assertEqual(len(result['staves'][0]['measures']), 12) self.assertEqual(len(result['staves'][0]['measures']), 12)
generator.model.validate_document(result) generator.validation.validate_document(result)
self.assertTrue(tests.score_events.timeline(result)['events']) self.assertTrue(tests.score_events.timeline(result)['events'])
self.assertTrue(any(n['kind'] == 'rest' for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m))) self.assertTrue(any(n['kind'] == 'rest' for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m)))
def test_melody_written_pitch_rhythm_rests_and_cross_bar_ties_are_preserved(self): def test_melody_written_pitch_rhythm_rests_and_cross_bar_ties_are_preserved(self):
@@ -97,7 +98,7 @@ class PartGenerationTests(unittest.TestCase):
for part in ('electric', 'vocal'): for part in ('electric', 'vocal'):
with self.subTest(builder=builder.__module__, part=part): with self.subTest(builder=builder.__module__, part=part):
result = builder(source, part) result = builder(source, part)
generator.model.validate_document(result) generator.validation.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
starts = [0] if builder is tests.arrangement_fixtures.section_document else [i for i, m in enumerate(measures) if m.get('section_label') == ('Guitar Melody' if part == 'electric' else 'Vocal Melody')] starts = [0] if builder is tests.arrangement_fixtures.section_document else [i for i, m in enumerate(measures) if m.get('section_label') == ('Guitar Melody' if part == 'electric' else 'Vocal Melody')]
self.assertTrue(starts) self.assertTrue(starts)
@@ -131,7 +132,7 @@ class PartGenerationTests(unittest.TestCase):
for name in names: for name in names:
(source / (name + '.xml')).write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) (source / (name + '.xml')).write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
with self.assertRaisesRegex(ValueError, 'conflicting output path') as raised: with self.assertRaisesRegex(ValueError, 'conflicting output path') as raised:
generator.arrange.generate.generate_performance_scores(source, output) generator.production.generate_tree(source, output)
for name in names: for name in names:
self.assertIn(str(source / (name + '.xml')), str(raised.exception)) self.assertIn(str(source / (name + '.xml')), str(raised.exception))
self.assertEqual([p.name for p in output.iterdir()], ['sentinel']) self.assertEqual([p.name for p in output.iterdir()], ['sentinel'])
@@ -148,7 +149,7 @@ class PartGenerationTests(unittest.TestCase):
unittest.mock.patch('generator.engraving.pipeline.engrave_tree'), unittest.mock.patch('generator.engraving.pipeline.engrave_tree'),
unittest.mock.patch('generator.pico_artwork.generate_titles'), unittest.mock.patch('generator.pico_artwork.generate_titles'),
unittest.mock.patch('generator.catalog.build_catalog'), unittest.mock.patch('generator.catalog.build_catalog'),
unittest.mock.patch('generator.performance.copy_audio', return_value=[]), unittest.mock.patch('generator.audio_assets.copy_audio', return_value=[]),
): ):
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
# Role catalog loading is replaced by this infrastructure fixture. The # Role catalog loading is replaced by this infrastructure fixture. The
+7 -6
View File
@@ -1,3 +1,4 @@
import generator.audio_assets
import argparse import argparse
import copy import copy
import hashlib import hashlib
@@ -41,7 +42,7 @@ def fixture(root):
with wave.open(str(path), 'wb') as output: with wave.open(str(path), 'wb') as output:
output.setparams((2, 2, 48000, 0, 'NONE', '')) output.setparams((2, 2, 48000, 0, 'NONE', ''))
output.writeframes(value.to_bytes(2, 'little', signed=True) * 2 * frames) output.writeframes(value.to_bytes(2, 'little', signed=True) * 2 * frames)
entries.append(generator.performance.describe_wav(path, root, song_id=song_id, bpm=bpm)) entries.append(generator.audio_assets.describe_wav(path, root, song_id=song_id, bpm=bpm))
(root / 'manifest.audio').write_text(json.dumps(entries)) (root / 'manifest.audio').write_text(json.dumps(entries))
class PerformanceTests(unittest.TestCase): class PerformanceTests(unittest.TestCase):
@@ -49,7 +50,7 @@ class PerformanceTests(unittest.TestCase):
source = pathlib.Path('package/assets') source = pathlib.Path('package/assets')
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary) root = pathlib.Path(temporary)
entries = generator.performance.copy_audio(source, root, {'all_of_me', 'autumn_leaves'}) entries = generator.audio_assets.copy_audio(source, root, {'all_of_me', 'autumn_leaves'})
self.assertEqual(len(entries), 4) self.assertEqual(len(entries), 4)
for entry in entries: for entry in entries:
self.assertEqual((source / entry['path']).read_bytes(), (root / entry['path']).read_bytes()) self.assertEqual((source / entry['path']).read_bytes(), (root / entry['path']).read_bytes())
@@ -61,10 +62,10 @@ class PerformanceTests(unittest.TestCase):
path = root / 'backing/fixture_120.wav' path = root / 'backing/fixture_120.wav'
path.write_bytes(path.read_bytes()[:-2]) path.write_bytes(path.read_bytes()[:-2])
with self.assertRaisesRegex(ValueError, 'truncated'): with self.assertRaisesRegex(ValueError, 'truncated'):
generator.performance.describe_wav(path, root) generator.audio_assets.describe_wav(path, root)
path.rename(root / 'backing/fixture_115.wav') path.rename(root / 'backing/fixture_115.wav')
with self.assertRaisesRegex(ValueError, 'unsupported'): with self.assertRaisesRegex(ValueError, 'unsupported'):
generator.performance.copy_audio(root, root / 'out', {'fixture'}) generator.audio_assets.copy_audio(root, root / 'out', {'fixture'})
def test_short_backing_cannot_publish(self): def test_short_backing_cannot_publish(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary) root = pathlib.Path(temporary)
@@ -73,10 +74,10 @@ class PerformanceTests(unittest.TestCase):
bundle = json.loads((root / 'score.json').read_text()) bundle = json.loads((root / 'score.json').read_text())
(root / 'songs/fixture.json').write_text(json.dumps(bundle['music'])) (root / 'songs/fixture.json').write_text(json.dumps(bundle['music']))
entries = json.loads((root / 'manifest.audio').read_text()) entries = json.loads((root / 'manifest.audio').read_text())
generator.performance.validate_recording_lengths(root, entries) generator.audio_assets.validate_recording_lengths(root, entries)
entries[-1]['frames'] = 1 entries[-1]['frames'] = 1
with self.assertRaisesRegex(ValueError, 'shorter'): with self.assertRaisesRegex(ValueError, 'shorter'):
generator.performance.validate_recording_lengths(root, entries) generator.audio_assets.validate_recording_lengths(root, entries)
def test_page_coverage_and_measure_accents(self): def test_page_coverage_and_measure_accents(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary) root = pathlib.Path(temporary)
+3 -4
View File
@@ -1,4 +1,5 @@
"""Build-only print layout, PDF contents and reproducible cache behavior.""" """Build-only print layout, PDF contents and reproducible cache behavior."""
import generator.score_io
import copy import copy
import dataclasses import dataclasses
import io import io
@@ -14,11 +15,9 @@ import PIL.PdfParser
import generator.arrange.generate import generator.arrange.generate
import generator.engraving.audit import generator.engraving.audit
import generator.engraving.fonts import generator.engraving.fonts
import generator.engraving.types
import generator.model import generator.model
import generator.musicxml import generator.musicxml
import generator.printing import generator.printing
import generator.score
import tests.solo_fixtures import tests.solo_fixtures
import tests.test_musicxml import tests.test_musicxml
@@ -35,7 +34,7 @@ class PrintableParts(unittest.TestCase):
def tree(self, name, document=None): def tree(self, name, document=None):
root = self.root / name root = self.root / name
(root / 'songs').mkdir(parents=True) (root / 'songs').mkdir(parents=True)
generator.score.write_json(root / 'songs/part.json', document or self.source) generator.score_io.write_json(root / 'songs/part.json', document or self.source)
return root return root
def test_letter_pagination_preserves_complete_order_without_transition_duplicates(self): def test_letter_pagination_preserves_complete_order_without_transition_duplicates(self):
source = tests.solo_fixtures.song(count=32, chords=('C7',)) source = tests.solo_fixtures.song(count=32, chords=('C7',))
@@ -137,7 +136,7 @@ class PrintableParts(unittest.TestCase):
generator.printing.generate_tree(root, self.font_path, cache_dir=cache) generator.printing.generate_tree(root, self.font_path, cache_dir=cache)
changed = copy.deepcopy(self.source) changed = copy.deepcopy(self.source)
changed['metadata']['title'] = 'Another title' changed['metadata']['title'] = 'Another title'
generator.score.write_json(root / 'songs/part.json', changed) generator.score_io.write_json(root / 'songs/part.json', changed)
with unittest.mock.patch('generator.printing.write_pdf', wraps=generator.printing.write_pdf) as render: with unittest.mock.patch('generator.printing.write_pdf', wraps=generator.printing.write_pdf) as render:
generator.printing.generate_tree(root, self.font_path, cache_dir=cache) generator.printing.generate_tree(root, self.font_path, cache_dir=cache)
render.assert_called_once() render.assert_called_once()
+7 -5
View File
@@ -4,7 +4,9 @@ import unittest
import unittest.mock import unittest.mock
import modulus.artifacts import modulus.artifacts
import modulus.cli import modulus.build_context
import modulus.packaging
import modulus.process
import modulus.config import modulus.config
import modulus.python import modulus.python
@@ -13,17 +15,17 @@ class PackagePublishingTests(unittest.TestCase):
root = pathlib.Path(__file__).resolve().parents[1] root = pathlib.Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
directory = pathlib.Path(temporary) directory = pathlib.Path(temporary)
context = modulus.cli.build_context(directory, modulus.config.load_repo_config(root)) context = modulus.build_context.build_context(directory, modulus.config.load_repo_config(root))
def fake_build(context, command, **kwargs): def fake_build(context, command, **kwargs):
if command[:2] == ['cmake', '--install']: if command[:2] == ['cmake', '--install']:
stage = pathlib.Path(command[2]).parent / 'stage' stage = pathlib.Path(command[2]).parent / 'stage'
(stage / 'manifest.json').write_text('{}\n') (stage / 'manifest.json').write_text('{}\n')
return 0 return 0
with unittest.mock.patch.object(modulus.cli, 'in_docker', return_value=True), \ with unittest.mock.patch.object(modulus.process, 'in_docker', return_value=True), \
unittest.mock.patch.object(modulus.cli, 'run', side_effect=fake_build), \ unittest.mock.patch.object(modulus.process, 'run', side_effect=fake_build), \
unittest.mock.patch.object(modulus.python, 'interpreter', return_value=pathlib.Path('/usr/bin/python3')), \ unittest.mock.patch.object(modulus.python, 'interpreter', return_value=pathlib.Path('/usr/bin/python3')), \
unittest.mock.patch.object(modulus.artifacts, 'main') as publish: unittest.mock.patch.object(modulus.artifacts, 'main') as publish:
self.assertEqual(modulus.cli.package_artifacts(context, ['--no-remote']), 0) self.assertEqual(modulus.packaging.package_artifacts(context, ['--no-remote']), 0)
publish.assert_not_called() publish.assert_not_called()
artifacts = modulus.artifacts.discover_artifacts(directory / 'build/packages', context.package['name'], context.artifact['targets']) artifacts = modulus.artifacts.discover_artifacts(directory / 'build/packages', context.package['name'], context.artifact['targets'])
self.assertEqual([p.name for p in artifacts], ['product_vamp_arm_64_gcc12_release.tar.gz', 'product_vamp_arm_64_gcc12_release.tar.gz.sha256']) self.assertEqual([p.name for p in artifacts], ['product_vamp_arm_64_gcc12_release.tar.gz', 'product_vamp_arm_64_gcc12_release.tar.gz.sha256'])
+15 -15
View File
@@ -1,3 +1,5 @@
import generator.score_io
import generator.validation
import tests.production_fixtures import tests.production_fixtures
import argparse import argparse
import copy import copy
@@ -14,7 +16,6 @@ import xml.etree.ElementTree
import PIL.Image import PIL.Image
import PIL.ImageChops import PIL.ImageChops
import generator.__main__
import generator.arrange.generate import generator.arrange.generate
import generator.engraving.fonts import generator.engraving.fonts
import generator.engraving.layout import generator.engraving.layout
@@ -30,7 +31,6 @@ import generator.performance
import generator.pitch import generator.pitch
import tests.score_events import tests.score_events
import tests.arrangement_fixtures import tests.arrangement_fixtures
import generator.score
import generator.theme import generator.theme
import tests.musicxml_roundtrip import tests.musicxml_roundtrip
import tests.test_musicxml import tests.test_musicxml
@@ -48,7 +48,7 @@ class ScoreBundleTests(unittest.TestCase):
self.assertEqual(set(document), {'metadata', 'staves'}) self.assertEqual(set(document), {'metadata', 'staves'})
for malformed in (None, [], {}, {'metadata': {}, 'staves': []}, {**document, 'staves': None}, {**document, 'staves': [{}]}): for malformed in (None, [], {}, {'metadata': {}, 'staves': []}, {**document, 'staves': None}, {**document, 'staves': [{}]}):
with self.subTest(document=malformed), self.assertRaises(ValueError): with self.subTest(document=malformed), self.assertRaises(ValueError):
generator.model.validate_document(malformed) generator.validation.validate_document(malformed)
def test_multiple_voices_keep_independent_ties_and_transformations(self): def test_multiple_voices_keep_independent_ties_and_transformations(self):
def half(action): def half(action):
return tests.test_musicxml.xml_note(duration=2, kind='half', extra=f'<voice>1</voice><tie type="{action}"/>') return tests.test_musicxml.xml_note(duration=2, kind='half', extra=f'<voice>1</voice><tie type="{action}"/>')
@@ -59,9 +59,9 @@ class ScoreBundleTests(unittest.TestCase):
self.assertEqual([v['id'] for v in measure['voices']], ['1', '2']) self.assertEqual([v['id'] for v in measure['voices']], ['1', '2'])
self.assertNotIn('tie_from', measure['voices'][1]['notes'][0]['pitches'][0]) self.assertNotIn('tie_from', measure['voices'][1]['notes'][0]['pitches'][0])
for document in (source, generator.pitch.concert_document(source), generator.arrange.generate.arrange_document(source, 'vocal')): for document in (source, generator.pitch.concert_document(source), generator.arrange.generate.arrange_document(source, 'vocal')):
generator.model.validate_document(document) generator.validation.validate_document(document)
restored = self.round_trip(document) restored = self.round_trip(document)
generator.model.validate_document(restored) generator.validation.validate_document(restored)
self.assertTrue(any(len(m['voices']) == 2 for m in restored['staves'][0]['measures'])) self.assertTrue(any(len(m['voices']) == 2 for m in restored['staves'][0]['measures']))
def test_contiguous_ties_continue_across_systems_without_repeat_state(self): def test_contiguous_ties_continue_across_systems_without_repeat_state(self):
text = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<tie type="start"/>')) text = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<tie type="start"/>'))
@@ -139,7 +139,7 @@ class ScoreBundleTests(unittest.TestCase):
tests.musicxml_roundtrip.audit_roundtrip(before, after) tests.musicxml_roundtrip.audit_roundtrip(before, after)
source['staves'][0]['instruments'] = [] source['staves'][0]['instruments'] = []
with self.assertRaisesRegex(ValueError, 'unknown score instrument'): with self.assertRaisesRegex(ValueError, 'unknown score instrument'):
generator.model.validate_document(source) generator.validation.validate_document(source)
def test_catalog_roundtrip_preserves_written_pitch_and_rhythm(self): def test_catalog_roundtrip_preserves_written_pitch_and_rhythm(self):
for path in sorted(pathlib.Path('data/songs').glob('*.xml')): for path in sorted(pathlib.Path('data/songs').glob('*.xml')):
with self.subTest(path=path): with self.subTest(path=path):
@@ -220,34 +220,34 @@ class ScoreBundleTests(unittest.TestCase):
bundle['performance'] = generator.performance.timing(document) bundle['performance'] = generator.performance.timing(document)
path = assets / 'score.json' path = assets / 'score.json'
path.write_text(json.dumps(bundle)) path.write_text(json.dumps(bundle))
self.assertEqual(generator.score.load_json(path), document) self.assertEqual(generator.score_io.load_json(path), document)
page.write_bytes(data + b'corrupt') page.write_bytes(data + b'corrupt')
with self.assertRaisesRegex(ValueError, 'checksum'): with self.assertRaisesRegex(ValueError, 'checksum'):
generator.score.load_json(path) generator.score_io.load_json(path)
page.unlink() page.unlink()
outside = root / 'outside.png' outside = root / 'outside.png'
outside.write_bytes(data) outside.write_bytes(data)
page.symlink_to(outside) page.symlink_to(outside)
with self.assertRaisesRegex(ValueError, 'escapes'): with self.assertRaisesRegex(ValueError, 'escapes'):
generator.score.load_json(path) generator.score_io.load_json(path)
def test_final_validation_reuses_content_checks_and_rechecks_files(self): def test_final_validation_reuses_content_checks_and_rechecks_files(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary) root = pathlib.Path(temporary)
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
for index in range(3): for index in range(3):
generator.score.write_json(root / 'songs' / f'{index}.json', document) generator.score_io.write_json(root / 'songs' / f'{index}.json', document)
trusted = generator.engraving.pipeline.engrave_tree(root, pathlib.Path('package/assets/fonts/petaluma')) trusted = generator.engraving.pipeline.engrave_tree(root, pathlib.Path('package/assets/fonts/petaluma'))
generator.pico_artwork.generate_titles(root, pathlib.Path('package/assets/fonts/petaluma')) generator.pico_artwork.generate_titles(root, pathlib.Path('package/assets/fonts/petaluma'))
with unittest.mock.patch('generator.engraving.validation.check_content', side_effect=AssertionError('already validated content')): with unittest.mock.patch('generator.engraving.validation.check_content', side_effect=AssertionError('already validated content')):
generator.score.validate_generated_tree(root, trusted) generator.score_io.validate_generated_tree(root, trusted)
page = root / 'songs/2.pages/0001.png' page = root / 'songs/2.pages/0001.png'
original = page.read_bytes() original = page.read_bytes()
page.write_bytes(original + b'changed') page.write_bytes(original + b'changed')
with self.assertRaisesRegex(ValueError, 'checksum'): with self.assertRaisesRegex(ValueError, 'checksum'):
generator.score.validate_generated_tree(root, trusted) generator.score_io.validate_generated_tree(root, trusted)
page.write_bytes(original) page.write_bytes(original)
with unittest.mock.patch('generator.engraving.validation.check_content', wraps=generator.engraving.validation.check_content) as decode: with unittest.mock.patch('generator.engraving.validation.check_content', wraps=generator.engraving.validation.check_content) as decode:
generator.score.validate_generated_tree(root) generator.score_io.validate_generated_tree(root)
decode.assert_called_once() decode.assert_called_once()
PIL.Image.new('RGB', (976, 484), generator.theme.rgb('background')).save(page) PIL.Image.new('RGB', (976, 484), generator.theme.rgb('background')).save(page)
path = root / 'songs/2.json' path = root / 'songs/2.json'
@@ -255,14 +255,14 @@ class ScoreBundleTests(unittest.TestCase):
bundle['presentation']['pages'][0]['sha256'] = hashlib.sha256(page.read_bytes()).hexdigest() bundle['presentation']['pages'][0]['sha256'] = hashlib.sha256(page.read_bytes()).hexdigest()
path.write_text(json.dumps(bundle)) path.write_text(json.dumps(bundle))
with self.assertRaisesRegex(ValueError, 'blank page'): with self.assertRaisesRegex(ValueError, 'blank page'):
generator.score.validate_generated_tree(root, trusted) generator.score_io.validate_generated_tree(root, trusted)
def test_python_engraver_visual_golden_and_determinism(self): def test_python_engraver_visual_golden_and_determinism(self):
document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml')) document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary) root = pathlib.Path(temporary)
before = None before = None
for _ in range(2): for _ in range(2):
generator.score.write_json(root / 'fixture.json', document) generator.score_io.write_json(root / 'fixture.json', document)
generator.engraving.pipeline.engrave_tree(root, pathlib.Path('package/assets/fonts/petaluma')) generator.engraving.pipeline.engrave_tree(root, pathlib.Path('package/assets/fonts/petaluma'))
bundle = json.loads((root / 'fixture.json').read_text()) bundle = json.loads((root / 'fixture.json').read_text())
self.assertEqual(bundle['music'], document) self.assertEqual(bundle['music'], document)
+33
View File
@@ -14,6 +14,7 @@
#include <boost/asio/redirect_error.hpp> #include <boost/asio/redirect_error.hpp>
#include <boost/asio/steady_timer.hpp> #include <boost/asio/steady_timer.hpp>
#include <boost/asio/use_awaitable.hpp> #include <boost/asio/use_awaitable.hpp>
#include <boost/asio/thread_pool.hpp>
#include <vamp_zero/errors.hpp> #include <vamp_zero/errors.hpp>
#include <vamp_zero/score_loader.hpp> #include <vamp_zero/score_loader.hpp>
@@ -93,6 +94,38 @@ BOOST_AUTO_TEST_CASE(score_loader_starts_asynchronously_from_executor_handler) {
BOOST_CHECK_EQUAL(*fixture.completions, 1U); BOOST_CHECK_EQUAL(*fixture.completions, 1U);
} }
BOOST_AUTO_TEST_CASE(score_loader_worker_keeps_control_executor_responsive_and_returns_completion) {
auto io = boost::asio::io_context{};
auto worker = boost::asio::thread_pool{1};
auto entered = std::promise<void>{};
auto release = std::promise<void>{};
auto resume = release.get_future().share();
auto completed = false;
auto control_thread = std::this_thread::get_id();
auto loader = vamp_zero::score_loader{io.get_executor(),
[&entered, resume](vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> {
entered.set_value();
resume.wait();
co_return vamp_zero::score_document{.id = reference.id};
},
[&] { completed = true; BOOST_CHECK(std::this_thread::get_id() == control_thread); }, worker.get_executor()};
loader.select(std::make_shared<vamp_zero::catalog>(), performance("a"));
auto started = entered.get_future().wait_for(std::chrono::seconds{2}) == std::future_status::ready;
auto serviced = false;
boost::asio::post(io, [&] { serviced = true; });
io.poll();
BOOST_CHECK(serviced);
BOOST_CHECK(!completed);
release.set_value();
worker.join();
io.restart();
io.run();
BOOST_REQUIRE(started);
BOOST_CHECK(completed);
BOOST_REQUIRE(loader.snapshot().document);
BOOST_CHECK_EQUAL(loader.snapshot().document->id, "a");
}
BOOST_AUTO_TEST_CASE(score_loader_coalesces_pending_and_discards_stale_results) { BOOST_AUTO_TEST_CASE(score_loader_coalesces_pending_and_discards_stale_results) {
auto fixture = loader_fixture{}; auto fixture = loader_fixture{};
fixture.loader.select(fixture.catalog, performance("a")); fixture.loader.select(fixture.catalog, performance("a"));
+2 -9
View File
@@ -3,18 +3,11 @@ import dataclasses
import fractions import fractions
import unittest import unittest
import generator.arrange.generate
import generator.arrange.form
import generator.model import generator.model
import generator.parts import generator.parts
import generator.solo.arrangement import generator.source
import generator.solo.analysis
import generator.solo.profiles import generator.solo.profiles
import generator.solo.placement import generator.solo.placement
import generator.solo.types
import generator.solo.harmonic
import tests.solo_fixtures
import tests.solo_fixtures
import tests.solo_fixtures import tests.solo_fixtures
def events(measure): def events(measure):
@@ -39,7 +32,7 @@ class SoloInstrumentAndIdentity(unittest.TestCase):
self.assertTrue(any(p.string_number == 1 for p in generator.solo.placement.positions(65, electric))) self.assertTrue(any(p.string_number == 1 for p in generator.solo.placement.positions(65, electric)))
self.assertTrue(any(p.string_number == 1 for p in generator.solo.placement.positions(65, acoustic))) self.assertTrue(any(p.string_number == 1 for p in generator.solo.placement.positions(65, acoustic)))
def test_soft_overlapping_registers_do_not_inherit_a_mandatory_octave(self): def test_soft_overlapping_registers_do_not_inherit_a_mandatory_octave(self):
source = generator.solo.analysis.analyze(generator.parts.prepare_document(tests.solo_fixtures.song(count=4, chords=('CM7',)))) source = generator.source.analyze(generator.parts.prepare_document(tests.solo_fixtures.song(count=4, chords=('CM7',))))
acoustic = generator.solo.profiles.for_part('acoustic', source) acoustic = generator.solo.profiles.for_part('acoustic', source)
electric = generator.solo.profiles.for_part('electric', source) electric = generator.solo.profiles.for_part('electric', source)
self.assertIsNone(electric.reference) self.assertIsNone(electric.reference)
+2 -1
View File
@@ -1,3 +1,4 @@
import generator.validation
import copy import copy
import pathlib import pathlib
import unittest import unittest
@@ -209,7 +210,7 @@ class VoicingTests(unittest.TestCase):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn_leaves.xml')) source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn_leaves.xml'))
before = copy.deepcopy(source) before = copy.deepcopy(source)
result = generator.arrange.generate.arrange_document(source, 'electric') result = generator.arrange.generate.arrange_document(source, 'electric')
generator.model.validate_document(result) generator.validation.validate_document(result)
section, checked = '', set() section, checked = '', set()
source_index = generator.score.body_start_measure_index(source['staves'][0]['measures']) source_index = generator.score.body_start_measure_index(source['staves'][0]['measures'])
for measure in result['staves'][0]['measures']: for measure in result['staves'][0]['measures']: