Package
ci / checks (push) Successful in 48m34s

This commit is contained in:
khodowany
2026-09-30 19:46:37 -05:00
parent 2a1aa2c67a
commit 09656a9d60
6621 changed files with 698 additions and 774 deletions
+5 -10
View File
@@ -6,6 +6,8 @@ import shutil
import tempfile
import time
import modulus.filesystem
import generator.arrange.generate
import generator.catalog
import generator.engraving.cache
@@ -71,7 +73,7 @@ def generate_assets(args: argparse.Namespace) -> None:
report('loading solo scale patterns')
scales = generator.scales.library.load_library(data_dir / 'scales')
output_dir.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.vamp-assets-', dir=output_dir.parent) as temporary:
with tempfile.TemporaryDirectory(prefix='.vamp_assets_', dir=output_dir.parent) as temporary:
staged = pathlib.Path(temporary) / 'assets'
staged.mkdir(parents=True, exist_ok=True)
copy_runtime_fonts(static_assets_dir, staged)
@@ -90,15 +92,8 @@ def generate_assets(args: argparse.Namespace) -> None:
generator.score.validate_generated_tree(staged, validated_pages)
report('building catalog database')
generator.catalog.build_catalog(staged)
backup = pathlib.Path(temporary) / 'previous'
if output_dir.exists():
output_dir.rename(backup)
try:
staged.rename(output_dir)
except OSError:
if backup.exists():
backup.rename(output_dir)
raise
with modulus.filesystem.publish_directories([(staged, output_dir)]):
pass
report('publishing complete')
print(f'Generated Product Vamp assets: {performance_count} performance scores.')
if args.check:
+2 -2
View File
@@ -205,7 +205,7 @@ def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None, *,
written = {'type': source.written_type, 'dots': source.dots} if unchanged else generator.model.written_for(duration)
if not unchanged and (lane == 'electric' or not chord):
duration, written = next(written_durations(duration))
event = generator.model.make_event(f'{measure["id"]}-rhythm-{lane}-{len(events)}', start, duration, 'note' if chord else 'rest', written=written)
event = generator.model.make_event(f'{measure["id"]}_rhythm_{lane}_{len(events)}', start, duration, 'note' if chord else 'rest', written=written)
if chord:
event['_harmony'] = chord
if lane:
@@ -215,7 +215,7 @@ def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None, *,
events.append(event)
cursor = start + duration
for duration, written in written_durations(end - cursor):
events.append(generator.model.make_event(f'{measure["id"]}-rhythm-{lane}-{len(events)}', cursor, duration, 'rest', written=written))
events.append(generator.model.make_event(f'{measure["id"]}_rhythm_{lane}_{len(events)}', cursor, duration, 'rest', written=written))
cursor += duration
normalize_beams(events)
return events
+6 -6
View File
@@ -67,11 +67,11 @@ def prepare(document: dict) -> None:
for note in voice['notes']:
start = generator.model.fraction(note['onset'])
if start > cursor:
notes.append(generator.model.make_event(f'{note["id"]}-release', cursor, start - cursor, 'rest') | {'voice': voice['id']})
notes.append(generator.model.make_event(f'{note["id"]}_release', cursor, start - cursor, 'rest') | {'voice': voice['id']})
notes.append(note)
cursor = start + generator.model.fraction(note['duration'])
if cursor < length:
notes.append(generator.model.make_event(f'{measure["id"]}-{voice["id"]}-release', cursor, length - cursor, 'rest') | {'voice': voice['id']})
notes.append(generator.model.make_event(f'{measure["id"]}_{voice["id"]}_release', cursor, length - cursor, 'rest') | {'voice': voice['id']})
voice['notes'] = notes
generator.accompaniment.normalize_beams(notes)
@@ -85,7 +85,7 @@ def combine(document: dict) -> None:
def rest(end):
nonlocal cursor
for duration, written in generator.accompaniment.written_durations(end - cursor):
notes.append(generator.model.make_event(f'{measure["id"]}-acoustic-{len(notes) + 1}', cursor, duration, 'rest', written=written) | {'voice': 'voice-arranged'})
notes.append(generator.model.make_event(f'{measure["id"]}_acoustic_{len(notes) + 1}', cursor, duration, 'rest', written=written) | {'voice': 'voice_arranged'})
cursor += duration
for start, group in itertools.groupby(attacks, key=lambda n: generator.model.fraction(n['onset'])):
@@ -94,9 +94,9 @@ def combine(document: dict) -> None:
pitches = sorted((p for n in group for p in n['pitches']), key=lambda p: (generator.model.pitch_number(p), p['tablature']['string']))
pitches = [{k: v for k, v in p.items() if k != 'stem'} for p in pitches]
duration = generator.model.fraction(group[0]['duration'])
notes.append(generator.model.make_event(f'{measure["id"]}-acoustic-{len(notes) + 1}', start, duration, 'note', pitches, group[0]['written']) | {'voice': 'voice-arranged'})
notes.append(generator.model.make_event(f'{measure["id"]}_acoustic_{len(notes) + 1}', start, duration, 'note', pitches, group[0]['written']) | {'voice': 'voice_arranged'})
cursor = start + duration
rest(length)
if not attacks and length == generator.model.meter_length(measure['meter']):
notes = [generator.model.make_event(f'{measure["id"]}-acoustic-1', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'voice': 'voice-arranged', 'measure_rest': True}]
measure['voices'] = [{'id': 'voice-arranged', 'notes': notes}]
notes = [generator.model.make_event(f'{measure["id"]}_acoustic_1', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'voice': 'voice_arranged', 'measure_rest': True}]
measure['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
+1 -1
View File
@@ -90,7 +90,7 @@ def place(document: dict) -> None:
for note in span.notes:
positions = (voicing.bass,) if note['_guitar_lane'] == 'bass' else voicing[1:]
for pitch in generator.fretboard.chord_pitches(span.harmony, positions):
note['pitches'].append({**pitch, 'id': f'{note["id"]}-s{pitch["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']})
note['pitches'].append({**pitch, 'id': f'{note["id"]}_s{pitch["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']})
for field in ('_harmony', '_guitar_profile', '_guitar_lane'):
note.pop(field, None)
document['staves'][0]['tablature'] = True
+6 -6
View File
@@ -76,7 +76,7 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
if section.role == 'accompaniment':
plan = plans[section.section_index]
rhythm = (plan.acoustic_bass[len(segment)], plan.acoustic_upper[len(segment)]) if part_type == 'acoustic' else plan.electric[len(segment)]
result = generator.parts.build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm)
result = generator.parts.build_measure(m, f'measure_{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm)
segment.append(result)
if part_type == 'electric' and section.role in ('melody', 'accompaniment'):
voicing_origins.append(generator.electric.MeasureOrigin(source_starts[si], section.role, result))
@@ -88,10 +88,10 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
for shift in staff.get('octave_shifts', []):
low, high = max(start, generator.model.fraction(shift['start'])), min(start + segment_length, generator.model.fraction(shift['end']))
if low < high:
octave_shifts.append({**shift, 'id': f'section-{section.section_index}-{shift["id"]}', 'start': generator.model.rational(absolute + low - start), 'end': generator.model.rational(absolute + high - start)})
octave_shifts.append({**shift, 'id': f'section_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(absolute + low - start), 'end': generator.model.rational(absolute + high - start)})
pickup = None
if section.role in ('vocal', 'melody') and generator.arrange.pickup.enters(section, source) and output:
pickup = generator.parts.build_measure(source[0], f'pickup-{section.section_index}', section.role, part_type, None)
pickup = generator.parts.build_measure(source[0], f'pickup_{section.section_index}', section.role, part_type, None)
tuplets.extend(generator.score.reidentify(([pickup] if pickup else []) + segment, staff.get('tuplets', [])))
if pickup:
destination = output[-1]
@@ -104,11 +104,11 @@ def _arrange_document(document: dict, part_type: str, plans: dict[int, generator
low, high = max(onset - pad, generator.model.fraction(shift['start'])), min(generator.model.fraction(pickup['duration']), generator.model.fraction(shift['end']))
if low < high:
offset = absolute - generator.model.fraction(pickup['duration'])
octave_shifts.append({**shift, 'id': f'pickup-{section.section_index}-{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)})
octave_shifts.append({**shift, 'id': f'pickup_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)})
output.extend(segment)
absolute += segment_length
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)
if part_type == 'acoustic':
generator.acoustic.prepare(result)
@@ -138,7 +138,7 @@ def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Pa
reports = []
for part in generator.parts.PART_TYPES:
try:
generator.score.write_json(output_dir / f'{generator.score.slugify(path.stem)}-{part}.json', _arrange_document(document, part, plans, scales=scales, reports=reports, source_name=path.name))
generator.score.write_json(output_dir / f'{generator.score.slugify(path.stem)}_{part}.json', _arrange_document(document, part, plans, scales=scales, reports=reports, source_name=path.name))
except ValueError as error:
raise ValueError(f'{path}, part {part}: {error}') from error
count += 1
+1 -1
View File
@@ -72,7 +72,7 @@ def build_catalog(root: pathlib.Path, destination: pathlib.Path | None = None) -
raise ValueError(f'{root}: score asset directory not found')
destination = destination or root / 'catalog.db'
destination.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.vamp-catalog-', dir=destination.parent) as temporary:
with tempfile.TemporaryDirectory(prefix='.vamp_catalog_', dir=destination.parent) as temporary:
staged = pathlib.Path(temporary) / 'catalog.db'
connection = sqlite3.connect(staged)
try:
+1 -1
View File
@@ -61,5 +61,5 @@ def reuse_voicings(links: list[VoicingLink]) -> None:
if strings not in generator.guitar.ELECTRIC_STRINGS:
continue
note = link.target
note['pitches'] = [copy.deepcopy({**{k: p[k] for k in ('letter', 'alter', 'octave', 'tablature')}, 'id': f'{note["id"]}-s{p["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']}) for p in reference['pitches']]
note['pitches'] = [copy.deepcopy({**{k: p[k] for k in ('letter', 'alter', 'octave', 'tablature')}, 'id': f'{note["id"]}_s{p["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']}) for p in reference['pitches']]
break
+3 -3
View File
@@ -31,7 +31,7 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
raise generator.engraving.types.EngravingError('music systems overlap their page rows')
audit_primitives(page.primitives, page.width, page.height, fonts)
header = generator.engraving.types.bounds([p for p in page.primitives if p.role in ('title', 'subtitle')])
footer = generator.engraving.types.bounds([p for p in page.primitives if p.role == 'page-number'])
footer = generator.engraving.types.bounds([p for p in page.primitives if p.role == 'page_number'])
if page.systems[0].box.top < header.bottom + layout.style.page_clearance - 0.01 or page.systems[-1].box.bottom > footer.top - layout.style.page_clearance + 0.01:
raise generator.engraving.types.EngravingError('music overlaps the page header or footer')
for item in page.primitives:
@@ -40,7 +40,7 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
for system in page.systems:
if system.scale != scale:
raise generator.engraving.types.EngravingError('notation size must remain consistent throughout a part')
for role in ('staff', 'tab-staff'):
for role in ('staff', 'tab_staff'):
lines = [p for p in system.primitives if p.role == role]
if lines:
left = min(x for p in lines for x, _ in p.points)
@@ -68,7 +68,7 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
if index.staff.get('tablature') and anchor.fragment.kind == 'tone':
tone = index.tones[anchor.source][1]
tab = tone['tablature']
tab_baseline = max(p.points[0][1] for p in system.primitives if p.role == 'tab-staff')
tab_baseline = max(p.points[0][1] for p in system.primitives if p.role == 'tab_staff')
expected_y = tab_baseline + (4 - (6 - tab['string']) * layout.style.tab_space) * system.scale
expected_x = min(a.x for a in system.anchors if a.event == anchor.event and a.time == anchor.time)
tabs = [p for p in system.primitives if p.role == 'tab' and anchor.source in p.sources]
+5 -4
View File
@@ -16,12 +16,13 @@ import PIL.ImageChops
import PIL.features
import generator.engraving.validation
import generator.theme
def renderer_identity(fonts, style) -> dict:
root = pathlib.Path(__file__).resolve().parents[1]
versions = {name: importlib.metadata.version(name) for name in ('Pillow', 'fonttools', 'aggdraw')}
versions.update({name: PIL.features.version(name) for name in PIL.features.get_supported()})
sources = [*root.joinpath('engraving').glob('*.py'), root / 'model.py', root / 'harmony.py', root / 'requirements.txt']
sources = [*root.joinpath('engraving').glob('*.py'), root / 'model.py', root / 'harmony.py', root / 'theme.py', root / 'palette.json']
return {'fonts_style': fonts.fingerprint(style), 'python': {'implementation': platform.python_implementation(), 'version': platform.python_version()}, 'dependencies': versions,
'sources': {path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(sources)}}
@@ -81,7 +82,7 @@ def store_pages(entry: pathlib.Path, destination: pathlib.Path, pages: list[dict
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:
with tempfile.TemporaryDirectory(prefix='.engraving_', dir=entry.parent) as temporary:
staged = pathlib.Path(temporary)
for page in pages:
shutil.copyfile(destination / page['path'], staged / page['path'])
@@ -113,7 +114,7 @@ def restore_band(entry: pathlib.Path, key: str, width: int, height: int) -> PIL.
if image.format != 'PNG' or image.size != (width, height) or image.mode != 'RGB':
return None
image.load()
ink = PIL.ImageChops.difference(image, PIL.Image.new('RGB', image.size, 'white')).getbbox()
ink = PIL.ImageChops.difference(image, PIL.Image.new('RGB', image.size, generator.theme.rgb('background'))).getbbox()
if not ink or ink[0] < 3 or ink[1] < 3 or ink[2] > width - 3 or ink[3] > height - 3:
return None
return image.copy()
@@ -127,7 +128,7 @@ def store_band(entry: pathlib.Path, key: str, image: PIL.Image.Image) -> None:
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:
with tempfile.TemporaryDirectory(prefix='.engraving_', dir=entry.parent) as temporary:
staged = pathlib.Path(temporary)
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()}
+2 -2
View File
@@ -57,7 +57,7 @@ def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fract
items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 18 if index.staff.get('tablature') else 21, 'harmony', (item['id'],)))
items.extend(generator.engraving.scales.column(index, time, start, fonts))
if time == measure_start:
items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure-number', (measure['id'],)))
items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure_number', (measure['id'],)))
if label := measure.get('section_label'):
items.append(fonts.text(label, 0, 0, 16 if index.staff.get('tablature') else 19, 'section', (measure['id'] + ':section',)))
return items, anchors, fragments
@@ -219,7 +219,7 @@ def paginate(systems: list[generator.engraving.types.System], document: dict, fo
index = index or generator.engraving.notation.ScoreIndex(document)
header = part_header(document, fonts, style)
top = generator.engraving.types.bounds(header).bottom + style.page_clearance
footers = [fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True) for number in range(1, (len(systems) + 1) // 2 + 1)]
footers = [fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page_number', (), True) for number in range(1, (len(systems) + 1) // 2 + 1)]
bottom = min(p.box.top for p in footers) - style.page_clearance
if bottom - top <= style.system_gap:
raise generator.engraving.types.EngravingError('page header leaves no room for two music systems')
+2 -2
View File
@@ -104,7 +104,7 @@ def draw_event(index: generator.engraving.notation.ScoreIndex, event: generator.
head = fonts.glyph(glyph, x, y, tone_size, 'head', sources)
group_items.append(head)
if tone.get('notehead', {}).get('parentheses') == 'yes':
group_items.extend((fonts.text('(', head.box.left - 6, y + 5, 18, 'head-parentheses', sources), fonts.text(')', head.box.right + 2, y + 5, 18, 'head-parentheses', sources)))
group_items.extend((fonts.text('(', head.box.left - 6, y + 5, 18, 'head_parentheses', sources), fonts.text(')', head.box.right + 2, y + 5, 18, 'head_parentheses', sources)))
ledger = -2
while position <= ledger:
group_items.append(generator.engraving.types.line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
@@ -149,7 +149,7 @@ def draw_event(index: generator.engraving.notation.ScoreIndex, event: generator.
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Up' if direction == -1 else 'Down')
group_items.append(fonts.glyph(flag, anchor.stem_x, stem_y, size, 'flag', references))
if note.get('grace', {}).get('slash'):
group_items.append(generator.engraving.types.line('grace-slash', references, ((anchor.stem_x - 5, stem_y + 14), (anchor.stem_x + 9, stem_y + 4)), 1.5))
group_items.append(generator.engraving.types.line('grace_slash', references, ((anchor.stem_x - 5, stem_y + 14), (anchor.stem_x + 9, stem_y + 4)), 1.5))
if note.get('tremolo', {}).get('type') == 'single':
for mark in range(note['tremolo']['marks']):
ty = stem_y - direction * (10 + mark * 5)
+2 -2
View File
@@ -116,7 +116,7 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
scores = {}
audited = set()
result = GroupResult()
with tempfile.TemporaryDirectory(prefix='vamp-notation-') as temporary:
with tempfile.TemporaryDirectory(prefix='vamp_notation_') as temporary:
bodies = BodyBands(pathlib.Path(temporary), cache_dir, result)
try:
for path, score_key in paths:
@@ -147,7 +147,7 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
audited.add(placement)
else:
for page in pages_layout:
decoration = tuple(p for p in page.primitives if p.role in ('title', 'subtitle', 'page-number'))
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 = []
+7 -6
View File
@@ -10,6 +10,7 @@ import fontTools.pens.basePen
import generator.engraving.fonts
import generator.engraving.types
import generator.theme
class OutlinePen(fontTools.pens.basePen.BasePen):
def __init__(self, glyphs, scale: float, x: float, y: float):
@@ -34,7 +35,7 @@ def rasterize_page(page: generator.engraving.types.Page, fonts: generator.engrav
def rasterize_band(page: generator.engraving.types.Page, primitives: tuple[generator.engraving.types.Primitive, ...], top: int, bottom: int, fonts: generator.engraving.fonts.Fonts) -> PIL.Image.Image:
scale = page.supersampling
image = PIL.Image.new('RGB', (page.width * scale, (bottom - top) * scale), 'white')
image = PIL.Image.new('RGB', (page.width * scale, (bottom - top) * scale), generator.theme.rgb('background'))
context = aggdraw.Draw(image)
for item in primitives:
points = [(x * scale, y * scale - top * scale) for x, y in item.points]
@@ -55,15 +56,15 @@ def rasterize_band(page: generator.engraving.types.Page, primitives: tuple[gener
if item.kind == 'polygon':
path.close()
if item.kind in ('glyph', 'polygon'):
context.path(path, None, aggdraw.Brush('black'))
context.path(path, None, aggdraw.Brush(generator.theme.rgb('ink')))
else:
context.path(path, aggdraw.Pen('black', item.thickness * scale))
context.path(path, aggdraw.Pen(generator.theme.rgb('ink'), item.thickness * scale))
context.flush()
drawing = PIL.ImageDraw.Draw(image)
for item in primitives:
if item.kind == 'text':
x, y = item.points[0]
drawing.text((x * scale, y * scale - top * scale), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill='black')
drawing.text((x * scale, y * scale - top * scale), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill=generator.theme.rgb('ink'))
return image.resize((page.width, bottom - top), PIL.Image.Resampling.LANCZOS)
@dataclasses.dataclass(frozen=True)
@@ -75,7 +76,7 @@ class Band:
def page_bands(page: generator.engraving.types.Page) -> tuple[Band, Band, Band] | None:
body = tuple(p for system in page.systems for p in system.primitives)
header = tuple(p for p in page.primitives if p.role in ('title', 'subtitle'))
footer = tuple(p for p in page.primitives if p.role == 'page-number')
footer = tuple(p for p in page.primitives if p.role == 'page_number')
if not body or not header or not footer or page.primitives != header + body + footer:
return None
bands = []
@@ -91,7 +92,7 @@ def page_bands(page: generator.engraving.types.Page) -> tuple[Band, Band, Band]
return tuple(bands)
def compose_page(page: generator.engraving.types.Page, fonts: generator.engraving.fonts.Fonts, bands: tuple[Band, Band, Band], body: PIL.Image.Image) -> PIL.Image.Image:
image = PIL.Image.new('RGB', (page.width, page.height), 'white')
image = PIL.Image.new('RGB', (page.width, page.height), generator.theme.rgb('background'))
if body.mode != 'RGB' or body.size != (page.width, bands[1].bottom - bands[1].top):
raise generator.engraving.types.EngravingError('notation band has incompatible geometry')
image.paste(body, (0, bands[1].top))
+3 -3
View File
@@ -65,7 +65,7 @@ def draw_beams(index: generator.engraving.notation.ScoreIndex, anchors: list[gen
x1 = left if index.by_id[group[0]].start < start else first.stem_x
x2 = right if index.by_id[group[-1]].start >= end else last.stem_x
for anchor in selected:
additions.append(generator.engraving.types.line('beam-stem', (anchor.event,), ((anchor.stem_x, anchor.stem_y), (anchor.stem_x, beam_y)), 1.4))
additions.append(generator.engraving.types.line('beam_stem', (anchor.event,), ((anchor.stem_x, anchor.stem_y), (anchor.stem_x, beam_y)), 1.4))
for level in range(1, max(a.flags for a in selected) + 1):
y = beam_y - direction * (level - 1) * style.space * 0.75
for i, anchor in enumerate(selected):
@@ -262,9 +262,9 @@ def draw_lyric_spans(index: generator.engraving.notation.ScoreIndex, items: list
if x2 <= x1:
continue
if extend in ('start', 'continue'):
result.append(generator.engraving.types.line('lyric-extension', (identifier,), ((x1, y + 3), (x2, y + 3)), 1))
result.append(generator.engraving.types.line('lyric_extension', (identifier,), ((x1, y + 3), (x2, y + 3)), 1))
elif following:
count = max(1, int((x2 - x1) / 90))
for dash in range(count):
result.append(fonts.text('-', x1 + (x2 - x1) * (dash + 1) / (count + 1), y, 18, 'lyric-hyphen', (identifier,), True))
result.append(fonts.text('-', x1 + (x2 - x1) * (dash + 1) / (count + 1), y, 18, 'lyric_hyphen', (identifier,), True))
return result
+5 -5
View File
@@ -10,7 +10,7 @@ def column(index, event, anchors, fonts, style):
sources = (event.value['id'],)
if not event.value['pitches']:
anchor = anchors[0]
return [fonts.glyph('noteheadSlashHorizontalEnds', x, -2 * style.tab_space, style.space, 'tab-slash', sources)] if anchor.fragment.kind == 'slash' else []
return [fonts.glyph('noteheadSlashHorizontalEnds', x, -2 * style.tab_space, style.space, 'tab_slash', sources)] if anchor.fragment.kind == 'slash' else []
result = []
for anchor in anchors:
tone = index.tones[anchor.source][1]
@@ -27,23 +27,23 @@ def staff(index, items, start, end, left, right, fonts, style):
offset = generator.engraving.types.bounds(notation).bottom + style.tab_gap + 5 * style.tab_space
numbers = [p.moved(0, offset) for p in numbers]
drawing = list(numbers)
drawing.append(fonts.text('TAB', 0, offset - 2 * style.tab_space, style.tab_font_size, 'tab-label', ()))
drawing.append(fonts.text('TAB', 0, offset - 2 * style.tab_space, style.tab_font_size, 'tab_label', ()))
for string in range(1, 7):
y = offset - (6 - string) * style.tab_space
cuts = sorted((max(0, p.box.left - 2), min(right, p.box.right + 2)) for p in numbers if p.box.top <= y <= p.box.bottom)
cursor = 0
for a, b in cuts + [(right, right)]:
if a > cursor:
drawing.append(generator.engraving.types.line('tab-staff', (), ((cursor, y), (a, y)), style.space * 0.13))
drawing.append(generator.engraving.types.line('tab_staff', (), ((cursor, y), (a, y)), style.space * 0.13))
cursor = max(cursor, b)
for bar in (p for p in notation if p.role == 'barline'):
drawing.append(generator.engraving.types.line('tab-barline', bar.sources, tuple((x, offset + y * 5 * style.tab_space / (4 * style.space)) for x, y in bar.points), bar.thickness))
drawing.append(generator.engraving.types.line('tab_barline', bar.sources, tuple((x, offset + y * 5 * style.tab_space / (4 * style.space)) for x, y in bar.points), bar.thickness))
by_source = {source: p for p in numbers if p.role == 'tab' for source in p.sources[1:]}
def tie(identifier, tone, x1, x2):
y = offset - (6 - tone['tablature']['string']) * style.tab_space + 6
if x2 > x1:
drawing.append(generator.engraving.types.curve('tab-tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y))))
drawing.append(generator.engraving.types.curve('tab_tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y))))
for identifier, (event, tone) in index.tones.items():
first = by_source.get(identifier)
+5 -3
View File
@@ -9,6 +9,7 @@ import PIL.Image
import PIL.ImageChops
import generator.engraving.types
import generator.theme
ENGINE_NAME = 'Vamp Python'
PAGE_WIDTH = 976
@@ -19,9 +20,10 @@ def check_content(data: bytes, name: str) -> None:
if image.format != 'PNG' or image.size != (PAGE_WIDTH, PAGE_HEIGHT):
raise generator.engraving.types.EngravingError(f'{name}: expected a {PAGE_WIDTH}x{PAGE_HEIGHT} PNG page')
image.load()
white = PIL.Image.new('RGBA', image.size, 'white')
white.alpha_composite(image.convert('RGBA'))
ink = PIL.ImageChops.difference(white.convert('RGB'), PIL.Image.new('RGB', image.size, 'white')).convert('L').point(lambda x: 255 if x > 32 else 0)
background = PIL.Image.new('RGB', image.size, generator.theme.rgb('background'))
composited = background.convert('RGBA')
composited.alpha_composite(image.convert('RGBA'))
ink = PIL.ImageChops.difference(composited.convert('RGB'), background).convert('L').point(lambda x: 255 if x > 32 else 0)
bounds = ink.getbbox()
if not bounds:
raise generator.engraving.types.EngravingError(f'{name}: engraver produced a blank page')
+7 -7
View File
@@ -166,7 +166,7 @@ class EventPlacement:
def continued_sources(notes):
result = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p}
result.update(f'{n["_continue_from"]}-s{s}' for n in notes if '_continue_from' in n for s, _ in generator.fretboard.STANDARD_TUNING)
result.update(f'{n["_continue_from"]}_s{s}' for n in notes if '_continue_from' in n for s, _ in generator.fretboard.STANDARD_TUNING)
return result
def has_attack(notes: list[dict]) -> bool:
@@ -206,11 +206,11 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
compatible = False
break
if '_continue_from' in note:
previous = {p for i, p in active.items() if i.startswith(note['_continue_from'] + '-s')}
previous = {p for i, p in active.items() if i.startswith(note['_continue_from'] + '_s')}
if previous != set(added):
compatible = False
break
assignment.update((f'{note["id"]}-s{p.string_number}', p) for p in added)
assignment.update((f'{note["id"]}_s{p.string_number}', p) for p in added)
choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added))
if not compatible:
continue
@@ -220,7 +220,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
shape = tuple(p for _, p in exact) + positions
if len({p.string_number for p in shape}) != len(shape) or not generator.fretboard.within_fret_span(shape):
continue
assignment = tuple(sorted((*exact, *((f'{chord["id"]}-s{p.string_number}', p) for p in positions)))) if chord else exact
assignment = tuple(sorted((*exact, *((f'{chord["id"]}_s{p.string_number}', p) for p in positions)))) if chord else exact
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)
cost = voicing_cost(shape, profile, requirements.allowed, melody) if fresh else VoicingCost()
yield AttackCandidate(assignment, choices, shape, cost)
@@ -244,7 +244,7 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
if source and '_harmony' in source:
note['_continue_from'] = source['id']
for string, _ in generator.fretboard.STANDARD_TUNING:
tones[f'{source["id"]}-s{string}'].pitch['tie_to'] = f'{note["id"]}-s{string}'
tones[f'{source["id"]}_s{string}'].pitch['tie_to'] = f'{note["id"]}_s{string}'
elif not note['pitches']:
note.pop('_harmony', None)
note['kind'] = 'rest'
@@ -264,7 +264,7 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
tones[tone['id']] = GuitarTone(tone, absolute + onset + generator.model.fraction(tone['duration']), options)
if '_harmony' in note:
for string, _ in generator.fretboard.STANDARD_TUNING:
tones[f'{note["id"]}-s{string}'] = GuitarTone({}, absolute + onset + generator.model.fraction(note['duration']), ())
tones[f'{note["id"]}_s{string}'] = GuitarTone({}, absolute + onset + generator.model.fraction(note['duration']), ())
if note['pitches'] or '_harmony' in note:
events.append((absolute + onset, not bool(note.get('grace')), order if note.get('grace') else -1, note))
absolute += generator.model.fraction(measure['duration'])
@@ -328,7 +328,7 @@ def place_guitar(document: dict) -> None:
tone['tablature'] = {'string': position.string_number, 'fret': position.fret}
if definition := note.pop('_harmony', None):
for pitch in generator.fretboard.chord_pitches(definition, choice.added):
note['pitches'].append({**pitch, 'id': f'{note["id"]}-s{pitch["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']})
note['pitches'].append({**pitch, 'id': f'{note["id"]}_s{pitch["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']})
if note.get('_harmonize') and not choice.added and not note.get('_rhythm_continuation'):
skipped.append(note['id'])
selected = selected.previous
+2 -2
View File
@@ -61,7 +61,7 @@ def harmonize(document: dict) -> None:
pieces = []
for index, (a, b) in enumerate(zip(cuts, cuts[1:])):
fragment = copy.deepcopy(note)
fragment['id'] = note['id'] if index == 0 else f'{note["id"]}-h{index}'
fragment['id'] = note['id'] if index == 0 else f'{note["id"]}_h{index}'
fragment['onset'] = generator.model.rational(generator.model.fraction(note['onset']) + a - start)
fragment['duration'] = generator.model.rational(b - a)
if len(cuts) > 2:
@@ -69,7 +69,7 @@ def harmonize(document: dict) -> None:
fragment.pop('beams', None)
for tone in fragment['pitches']:
original = tone['id']
tone['id'] = original if index == 0 else f'{original}-h{index}'
tone['id'] = original if index == 0 else f'{original}_h{index}'
tone['duration'], tone['written'] = fragment['duration'], copy.deepcopy(fragment['written'])
tone.pop('tie_from', None)
tone.pop('tie_to', None)
+1 -1
View File
@@ -134,7 +134,7 @@ def make_event(identifier: str, onset: fractions.Fraction, duration: fractions.F
notation = written or written_for(duration)
event = {'id': identifier, 'kind': kind, 'onset': rational(onset), 'duration': rational(duration), 'written': notation, 'hidden': False, 'pitches': []}
for i, p in enumerate(pitches or []):
event['pitches'].append({**copy.deepcopy(p), 'id': f'{identifier}-p{i + 1}', 'duration': rational(duration), 'written': copy.deepcopy(notation)})
event['pitches'].append({**copy.deepcopy(p), 'id': f'{identifier}_p{i + 1}', 'duration': rational(duration), 'written': copy.deepcopy(notation)})
if 'alter' in event['pitches'][-1]:
event['pitches'][-1]['alter'] = rational(alteration(p['alter']))
return event
+8 -8
View File
@@ -176,7 +176,7 @@ class Importer:
self.identity(node)
kind = node.find('kind')
self.harmony_sequence += 1
result = {'id': f'harmony-{self.harmony_sequence}', 'onset': generator.model.rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []}
result = {'id': f'harmony_{self.harmony_sequence}', 'onset': generator.model.rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []}
self.attrs(kind, {'text', 'use-symbols', 'stack-degrees', 'parentheses-degrees', 'bracket-degrees'})
for tag in ('root', 'bass'):
item = node.find(tag)
@@ -247,7 +247,7 @@ class Importer:
if action in ('up', 'down'):
if number in self.open_octaves:
self.fail('duplicate octave-shift start')
span = {'id': f'octave-{len(self.octaves) + 1}', 'start': position, 'octaves': (size - 1) // 7 * (1 if action == 'up' else -1)}
span = {'id': f'octave_{len(self.octaves) + 1}', 'start': position, 'octaves': (size - 1) // 7 * (1 if action == 'up' else -1)}
self.open_octaves[number] = span
self.octaves.append(span)
elif action == 'stop':
@@ -313,7 +313,7 @@ class Importer:
if duration != expected:
self.fail(f'<note> duration {duration} disagrees with written duration/time-modification {expected}')
self.sequence += 1
note = {'id': f'note-{self.sequence}', 'voice': voice, 'kind': 'rest' if node.find('rest') is not None else 'note', 'onset': generator.model.rational(onset), 'duration': generator.model.rational(duration), 'written': written, 'hidden': node.get('print-object') == 'no', 'pitches': []}
note = {'id': f'note_{self.sequence}', 'voice': voice, 'kind': 'rest' if node.find('rest') is not None else 'note', 'onset': generator.model.rational(onset), 'duration': generator.model.rational(duration), 'written': written, 'hidden': node.get('print-object') == 'no', 'pitches': []}
if is_chord:
note = previous
if grace is not None and not is_chord:
@@ -333,7 +333,7 @@ class Importer:
note['rest_position'] = {'letter': node.findtext('rest/display-step').lower(), 'octave': self.number(node.findtext('rest/display-octave'), integer=True)}
else:
pitch_node = node.find('pitch')
p = {'id': f'{note["id"]}-p{len(note["pitches"]) + 1}', 'duration': generator.model.rational(duration), 'written': written}
p = {'id': f'{note["id"]}_p{len(note["pitches"]) + 1}', 'duration': generator.model.rational(duration), 'written': written}
if pitch_node is not None:
self.children(pitch_node, {'step', 'alter', 'octave'})
p.update({'letter': pitch_node.findtext('step', '').lower(), 'alter': generator.model.rational(self.number(pitch_node.findtext('alter', '0'))), 'octave': self.number(pitch_node.findtext('octave'), integer=True)})
@@ -448,7 +448,7 @@ class Importer:
active = [t for (v, _), t in self.active_tuplets.items() if v == voice]
if active and (item.find('tuplet-actual') is None or item.find('tuplet-normal') is None):
self.fail('nested tuplets require explicit tuplet-actual and tuplet-normal numbers')
span = {'id': f'tuplet-{self.tuplet_sequence}', 'voice': voice, 'start': note['id'], 'note_count': self.number(actual, integer=True, positive=True), 'space_count': self.number(normal, integer=True, positive=True), 'depth': len(active), 'bracket': item.get('bracket', 'auto'), 'show_number': item.get('show-number', 'actual'), 'show_type': item.get('show-type', 'none'), 'line_shape': item.get('line-shape', 'straight')}
span = {'id': f'tuplet_{self.tuplet_sequence}', 'voice': voice, 'start': note['id'], 'note_count': self.number(actual, integer=True, positive=True), 'space_count': self.number(normal, integer=True, positive=True), 'depth': len(active), 'bracket': item.get('bracket', 'auto'), 'show_number': item.get('show-number', 'actual'), 'show_type': item.get('show-type', 'none'), 'line_shape': item.get('line-shape', 'straight')}
for tag in ('tuplet-actual', 'tuplet-normal'):
detail = item.find(tag)
if detail is not None:
@@ -532,7 +532,7 @@ class Importer:
voices = {}
previous = None
optional = []
measure = {'id': f'measure-{index + 1}', 'number': bar.get('number', str(index + 1)), 'implicit': bar.get('implicit') == 'yes', 'harmonies': [], 'attributes': [], 'key': copy.deepcopy(self.key), 'meter': copy.deepcopy(self.meter), 'clef': copy.deepcopy(self.clef), 'transpose': copy.deepcopy(self.transpose), 'staff_details': copy.deepcopy(self.staff_details)}
measure = {'id': f'measure_{index + 1}', 'number': bar.get('number', str(index + 1)), 'implicit': bar.get('implicit') == 'yes', 'harmonies': [], 'attributes': [], 'key': copy.deepcopy(self.key), 'meter': copy.deepcopy(self.meter), 'clef': copy.deepcopy(self.clef), 'transpose': copy.deepcopy(self.transpose), 'staff_details': copy.deepcopy(self.staff_details)}
for item in bar:
if item.tag == 'attributes':
state = self.attributes(item, cursor)
@@ -564,7 +564,7 @@ class Importer:
duration = self.number(item.findtext('duration'), positive=True) / self.divisions
if item.find('voice') is not None:
self.sequence += 1
voices.setdefault(self.voice_id, []).append(generator.model.make_event(f'spacer-{self.sequence}', cursor, duration, 'spacer') | {'voice': self.voice_id})
voices.setdefault(self.voice_id, []).append(generator.model.make_event(f'spacer_{self.sequence}', cursor, duration, 'spacer') | {'voice': self.voice_id})
cursor += duration
extent = max(extent, cursor)
previous = None
@@ -619,7 +619,7 @@ class Importer:
for p in n['pitches']:
p.pop('_absolute', None)
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
generator.model.validate_document(result)
return result
+13
View File
@@ -0,0 +1,13 @@
{
"background": "#111513",
"panel": "#1c221f",
"ink": "#dce5df",
"muted": "#a5b3aa",
"rule": "#48574f",
"accent": "#80c9b5",
"selection": "#293d34",
"connecting": "#9fb9cc",
"retrying": "#d6b879",
"connected": "#80c9b5",
"danger": "#e79891"
}
+2 -2
View File
@@ -22,7 +22,7 @@ def source_paths(directory: pathlib.Path) -> list[pathlib.Path]:
raise ValueError(f'{path}: performance sources belong directly under data/songs')
identity = generator.score.slugify(path.stem)
if identity in identities:
raise ValueError(f'{identities[identity]} and {path}: conflicting output path {identity}-acoustic.json')
raise ValueError(f'{identities[identity]} and {path}: conflicting output path {identity}_acoustic.json')
identities[identity] = path
return paths
@@ -45,7 +45,7 @@ def generated_voice(measure: dict, role: str) -> list[dict]:
beat_index = 0
while onset < end:
duration = min(beats[beat_index % len(beats)] if beats else end - onset, end - onset)
event = generator.model.make_event(f'slash-{len(events)}', onset, duration, 'note')
event = generator.model.make_event(f'slash_{len(events)}', onset, duration, 'note')
event['notehead'] = 'slash'
events.append(event)
onset += duration
+1 -1
View File
@@ -89,6 +89,6 @@ def concert_document(document: dict, *, octave_transposing: bool = False) -> dic
def sounding_pitch(pitch: dict, state: dict):
if 'unpitched' in pitch:
raise ValueError('unpitched notes have no fretted or generated-tone interpretation')
raise ValueError('unpitched notes have no fretted or generated_tone interpretation')
transpose = state['transpose']
return generator.model.pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change']
+4 -4
View File
@@ -13,7 +13,7 @@ import generator.model
import generator.pitch
SAMPLE_RATE = 48000
PROFILE = 'soft-struck'
PROFILE = 'soft_struck'
MAX_EVENTS = 1000000
class PlaybackUnavailable(ValueError):
@@ -21,11 +21,11 @@ class PlaybackUnavailable(ValueError):
def sounding_pitch(tone: dict, state: dict) -> fractions.Fraction:
if 'unpitched' in tone:
raise PlaybackUnavailable('unpitched notation has no generated-tone interpretation')
raise PlaybackUnavailable('unpitched notation has no generated_tone interpretation')
value = generator.pitch.sounding_pitch(tone, state)
frequency = 440 * 2 ** (float(value - 69) / 12)
if not 20 <= frequency <= 18000:
raise PlaybackUnavailable(f'sounding pitch {value} is outside the generated-tone range (20-18000 Hz)')
raise PlaybackUnavailable(f'sounding pitch {value} is outside the generated_tone range (20-18000 Hz)')
return value
def compile_timeline(document: dict) -> dict:
@@ -133,7 +133,7 @@ def compile_timeline(document: dict) -> dict:
if tremolo['type'] == 'start' and attack % 2:
other = partner[0]['pitches'][note['pitches'].index(pitch)]
amount = sounding_pitch(other, partner[2])
events.append({'id': f'{pitch["id"]}-{attack}', 'pitch': generator.model.rational(amount), 'onset': generator.model.rational(performed + cursor), 'duration': generator.model.rational(min(step, length - cursor))})
events.append({'id': f'{pitch["id"]}_{attack}', 'pitch': generator.model.rational(amount), 'onset': generator.model.rational(performed + cursor), 'duration': generator.model.rational(min(step, length - cursor))})
cursor += step
attack += 1
if len(events) > MAX_EVENTS:
-4
View File
@@ -1,4 +0,0 @@
Pillow==12.3.0
defusedxml==0.7.1
fonttools==4.65.0
aggdraw==1.4.1
+2 -2
View File
@@ -84,10 +84,10 @@ def load_library(root: pathlib.Path) -> Library:
snippets = []
for path in sorted(root.rglob('*.xml')):
relative = path.relative_to(root)
if len(relative.parts) != 3 or relative.parts[0] not in ('2-4', '3-4', '4-4', '5-4') or relative.parts[1] not in ('1', '2', '3'):
if len(relative.parts) != 3 or relative.parts[0] not in ('2_4', '3_4', '4_4', '5_4') or relative.parts[1] not in ('1', '2', '3'):
raise ValueError(f'{path}: rhythm sources belong under meter/series')
snippet = read_snippet(path, relative.as_posix())
if snippet.meter != relative.parts[0].replace('-', '/'):
if snippet.meter != relative.parts[0].replace('_', '/'):
raise ValueError(f'{path}: rhythm meter disagrees with its directory')
snippets.append(snippet)
if not snippets:
+5 -5
View File
@@ -9,8 +9,8 @@ import generator.pitch
NAMES = {
'major': ('Ionian', 'Dorian', 'Phrygian', 'Lydian', 'Mixolydian', 'Aeolian', 'Locrian'),
'melodic-minor': ('Melodic Minor', 'Dorian b2', 'Lydian Augmented', 'Lydian Dominant', 'Mixolydian b6', 'Locrian natural 2', 'Altered'),
'harmonic-minor': ('Harmonic Minor', 'Locrian natural 6', 'Ionian #5', 'Dorian #4', 'Phrygian Dominant', 'Lydian #2', 'Ultra Locrian'),
'melodic_minor': ('Melodic Minor', 'Dorian b2', 'Lydian Augmented', 'Lydian Dominant', 'Mixolydian b6', 'Locrian natural 2', 'Altered'),
'harmonic_minor': ('Harmonic Minor', 'Locrian natural 6', 'Ionian #5', 'Dorian #4', 'Phrygian Dominant', 'Lydian #2', 'Ultra Locrian'),
}
ROOTS = ('C', 'Db', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B')
@@ -76,17 +76,17 @@ def read_pattern(path: pathlib.Path, family: str, mode: int) -> Pattern:
return Pattern(family, mode, tuple(int(n) for n in intervals[:-1]))
def load_library(root: pathlib.Path) -> Library:
expected = {root / f'{family}-scale-modes' / f'{mode:04}.xml' for family in NAMES for mode in range(1, 8)}
expected = {root / f'{family}_scale_modes' / f'{mode:04}.xml' for family in NAMES for mode in range(1, 8)}
actual = set(root.rglob('*.xml'))
if actual != expected:
missing, unexpected = sorted(expected - actual), sorted(actual - expected)
raise ValueError(f'{root}: expected all 21 scale patterns; missing: {missing}; unexpected: {unexpected}')
patterns = tuple(read_pattern(root / f'{family}-scale-modes' / f'{mode:04}.xml', family, mode) for family in NAMES for mode in range(1, 8))
patterns = tuple(read_pattern(root / f'{family}_scale_modes' / f'{mode:04}.xml', family, mode) for family in NAMES for mode in range(1, 8))
for family in NAMES:
modes = [pattern for pattern in patterns if pattern.family == family]
parent = modes[0].intervals
for index, pattern in enumerate(modes):
rotation = tuple((parent[(index + n) % 7] - parent[index]) % 12 for n in range(7))
if pattern.intervals != rotation:
raise ValueError(f'{root / (family + "-scale-modes") / f"{pattern.mode:04}.xml"}: pattern is not mode {pattern.mode} of its family parent')
raise ValueError(f'{root / (family + "_scale_modes") / f"{pattern.mode:04}.xml"}: pattern is not mode {pattern.mode} of its family parent')
return Library(patterns)
+1 -1
View File
@@ -22,7 +22,7 @@ def annotate(document: dict, solos: list[generator.scales.timeline.Solo], librar
if previous is not None and previous['label'] == label and generator.model.fraction(previous['end']) == choice.span.start:
previous['end'] = generator.model.rational(choice.span.end)
else:
previous = {'id': f'scale-{len(spans) + 1}', 'start': generator.model.rational(choice.span.start), 'end': generator.model.rational(choice.span.end), 'label': label, 'matched': choice.scale is not None}
previous = {'id': f'scale_{len(spans) + 1}', 'start': generator.model.rational(choice.span.start), 'end': generator.model.rational(choice.span.end), 'label': label, 'matched': choice.scale is not None}
spans.append(previous)
if choice.scale is None:
print(f'{source}, {staff["name"]}, source measure {choice.span.source_measure}, beat {choice.span.beat}: {choice.reason} Chord: {generator.harmony.symbol(choice.span.chord)}', flush=True)
+5 -5
View File
@@ -27,22 +27,22 @@ def destination(chord: dict, following: dict | None, library: generator.scales.l
if following and dominant(chord) and (root_number(chord) - root_number(following)) % 12 == 7:
third = generator.harmony.degrees(following).get(3)
if third in (-1, 0):
return library.scale('harmonic-minor' if third == -1 else 'major', 1, generator.model.pitch_name(following['root']))
return library.scale('harmonic_minor' if third == -1 else 'major', 1, generator.model.pitch_name(following['root']))
return None
def preference(chord: dict, following: dict | None, after: dict | None, home: generator.scales.library.Scale, library: generator.scales.library.Library) -> tuple[generator.scales.library.Scale, str]:
if any(root_number(item).denominator != 1 for item in (chord, following, after) if item):
return home, 'non-semitone harmony cannot establish a destination in the permitted catalog'
if target := destination(chord, following, library):
return target, 'dominant approaching ' + generator.model.pitch_name(following['root']) + (' minor' if target.pattern.family == 'harmonic-minor' else ' major')
return target, 'dominant approaching ' + generator.model.pitch_name(following['root']) + (' minor' if target.pattern.family == 'harmonic_minor' else ' major')
degrees = generator.harmony.degrees(chord)
if following and degrees.get(3) == -1 and degrees.get(7) == -1 and dominant(following) and (root_number(following) - root_number(chord)) % 12 == 5:
tonic = generator.pitch.shift_pitch({**chord['root'], 'octave': 4}, -1, -2)
target = destination(following, after, library) or library.scale('harmonic-minor' if degrees.get(5) == -1 else 'major', 1, generator.model.pitch_name(tonic))
target = destination(following, after, library) or library.scale('harmonic_minor' if degrees.get(5) == -1 else 'major', 1, generator.model.pitch_name(tonic))
if set(generator.harmony.pitch_classes(chord)) | set(generator.harmony.pitch_classes(following)) <= target.pitches:
return target, 'shared ' + ('minor ii-V' if target.pattern.family == 'harmonic-minor' else 'major ii-V') + ' destination ' + target.root
return target, 'shared ' + ('minor ii-V' if target.pattern.family == 'harmonic_minor' else 'major ii-V') + ' destination ' + target.root
if following and degrees.get(3) == -1 and degrees.get(5) == -1 and degrees.get(7) == -2 and (root_number(following) - root_number(chord)) % 12 == 1:
return library.scale('harmonic-minor', 1, generator.model.pitch_name(following['root'])), 'leading-tone diminished resolution to ' + generator.model.pitch_name(following['root'])
return library.scale('harmonic_minor', 1, generator.model.pitch_name(following['root'])), 'leading-tone diminished resolution to ' + generator.model.pitch_name(following['root'])
return home, 'declared tonal center ' + home.label()
def following_indices(timeline: tuple[generator.scales.timeline.ChordSpan, ...]) -> list[int | None]:
+7 -7
View File
@@ -44,7 +44,7 @@ def write_json(path: pathlib.Path, document: dict) -> None:
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
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'
def source_staff(document: dict) -> dict:
if len(document.get('staves', [])) != 1:
@@ -71,9 +71,9 @@ def copy_source_voices(measure: dict, *, strip_lyrics: bool) -> list[dict]:
def make_measure(measure_id: str, source: dict, notes: list[dict], *, section_label: str = '') -> dict:
result = copy.deepcopy(source)
result.update({'id': measure_id, 'section_label': section_label, 'voices': [{'id': 'voice-arranged', 'notes': notes}]})
result.update({'id': measure_id, 'section_label': section_label, 'voices': [{'id': 'voice_arranged', 'notes': notes}]})
for note in notes:
note['voice'] = 'voice-arranged'
note['voice'] = 'voice_arranged'
return result
def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
@@ -82,14 +82,14 @@ def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
tuplets = []
for measure in measures:
for hi, harmony in enumerate(measure.get('harmonies', [])):
harmony['id'] = f'{measure["id"]}-h{hi + 1}'
harmony['id'] = f'{measure["id"]}_h{hi + 1}'
for ni, note in enumerate(measure_notes(measure)):
old = note['id']
note['id'] = f'{measure["id"]}-n{ni + 1}'
note['id'] = f'{measure["id"]}_n{ni + 1}'
event_map[old] = note['id']
for pi, pitch in enumerate(note['pitches']):
old_pitch = pitch['id']
pitch['id'] = f'{note["id"]}-p{pi + 1}'
pitch['id'] = f'{note["id"]}_p{pi + 1}'
tone_map[old_pitch] = pitch['id']
for measure in measures:
for note in measure_notes(measure):
@@ -111,7 +111,7 @@ def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
if not members:
continue
t = copy.deepcopy(source)
t.update({'id': f'{measures[0]["id"]}-{source["id"]}', 'start': members[0]['id'], 'end': members[-1]['id'], 'continued_start': source['start'] not in event_map, 'continued_end': source['end'] not in event_map})
t.update({'id': f'{measures[0]["id"]}_{source["id"]}', 'start': members[0]['id'], 'end': members[-1]['id'], 'continued_start': source['start'] not in event_map, 'continued_end': source['end'] not in event_map})
for n in members:
n['tuplet_ids'] = [t['id'] if v == source['id'] else v for v in n['tuplet_ids']]
tuplets.append(t)
+38
View File
@@ -0,0 +1,38 @@
"""The product palette shared by build-time rasterization and both devices."""
from __future__ import annotations
import argparse
import json
import pathlib
import re
PALETTE_PATH = pathlib.Path(__file__).with_name('palette.json')
COLORS = json.loads(PALETTE_PATH.read_text(encoding='utf-8'))
def rgb(name: str) -> tuple[int, int, int]:
value = COLORS[name]
if not isinstance(value, str) or not re.fullmatch(r'#[0-9a-fA-F]{6}', value):
raise ValueError(f'invalid palette color: {name}')
return tuple(int(value[index:index + 2], 16) for index in (1, 3, 5))
def generate_header(destination: pathlib.Path) -> None:
lines = ['#pragma once', '', 'namespace vamp_style {', '']
for name in COLORS:
if not re.fullmatch(r'[a-z][a-z_]*', name):
raise ValueError(f'invalid palette name: {name}')
r, g, b = rgb(name)
lines.append(f'constexpr auto {name} = vamp_style::color{{{r}, {g}, {b}, 255}};')
lines.extend(['', '}', ''])
content = '\n'.join(lines)
destination.parent.mkdir(parents=True, exist_ok=True)
if not destination.exists() or destination.read_text() != content:
destination.write_text(content, encoding='utf-8')
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--header', required=True, type=pathlib.Path)
generate_header(parser.parse_args().header)
if __name__ == '__main__':
main()