Music Notation
ci / checks (push) Successful in 43m19s

This commit is contained in:
khodowany
2026-09-15 01:30:42 -05:00
parent fe42d811fa
commit 1d1bd3e871
41 changed files with 1546 additions and 5236 deletions
+5 -4
View File
@@ -10,8 +10,9 @@ from generator.practice.generate import generate_practice_scores
from generator.score import count_json_files, validate_generated_tree
from generator.summary import validate_expected_performance_summary, validate_practice_variants
from generator.musicxml import load_musicxml
from generator.engraving import engrave_tree
RUNTIME_FONT_FILES = ("Petaluma.otf", "PetalumaScript.otf", "OFL.txt")
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
def copy_runtime_fonts(static_assets_dir: Path, output_dir: Path) -> None:
source = static_assets_dir / "fonts" / "petaluma"
@@ -25,7 +26,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--data-dir", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--static-assets-dir", type=Path, default=Path("package/assets"))
parser.add_argument("--clean", action="store_true")
parser.add_argument("--clean", action="store_true", help="Compatibility option; generated assets are always replaced as a complete bundle.")
parser.add_argument("--check", action="store_true")
return parser.parse_args()
@@ -38,8 +39,6 @@ def generate_assets(args: argparse.Namespace) -> None:
output_dir.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.vamp-assets-', dir=output_dir.parent) as temporary:
staged = Path(temporary) / 'assets'
if not args.clean and output_dir.exists():
shutil.copytree(output_dir, staged)
staged.mkdir(parents=True, exist_ok=True)
copy_runtime_fonts(static_assets_dir, staged)
performance_count = generate_performance_scores(data_dir / 'songs', staged / 'songs')
@@ -48,6 +47,8 @@ def generate_assets(args: argparse.Namespace) -> None:
if args.check:
validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
validate_practice_variants(staged / 'practice')
engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma')
validate_generated_tree(staged)
backup = Path(temporary) / 'previous'
if output_dir.exists():
output_dir.rename(backup)
+6 -6
View File
@@ -5,7 +5,7 @@ from typing import NamedTuple
from generator.transposition import mod12
from generator import harmony
from generator.model import NATURAL
from generator.model import NATURAL, alteration
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
@@ -50,10 +50,10 @@ def find_playable_voicing(symbol: str | dict) -> list[TabPosition]:
if len(voicing) > 4 and voicing.get(degree) == 0:
del voicing[degree]
root = definition.get('root', {'letter': 'c', 'alter': 0})
root_pc = NATURAL[root['letter']] + root['alter']
root_pc = NATURAL[root['letter']] + alteration(root['alter'])
pitch_classes = tuple(dict.fromkeys((root_pc + harmony.degree_semitones(d, a)) % 12 for d, a in voicing.items()))
bass = definition.get('bass')
bass_pc = (NATURAL[bass['letter']] + bass['alter']) % 12 if bass else None
bass_pc = (NATURAL[bass['letter']] + alteration(bass['alter'])) % 12 if bass else None
if bass_pc is not None and bass_pc not in pitch_classes:
pitch_classes += (bass_pc,)
if not pitch_classes:
@@ -88,13 +88,13 @@ def pitches_for_voicing(symbol: str | dict) -> list[dict[str, int | str]]:
spellings = {}
for degree, alter in harmony.degrees(definition).items():
p = shift_pitch(root, degree - 1, harmony.degree_semitones(degree, alter))
spellings[(NATURAL[p['letter']] + p['alter']) % 12] = p
spellings[(NATURAL[p['letter']] + alteration(p['alter'])) % 12] = p
if bass := definition.get('bass'):
spellings[(NATURAL[bass['letter']] + bass['alter']) % 12] = bass
spellings[(NATURAL[bass['letter']] + alteration(bass['alter'])) % 12] = bass
result = []
for position in find_playable_voicing(definition):
midi = position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET
p = spellings[midi % 12].copy()
p['octave'] = (midi - NATURAL[p['letter']] - p['alter']) // 12 - 1
p['octave'] = int((midi - NATURAL[p['letter']] - alteration(p['alter'])) // 12 - 1)
result.append(p)
return result
+18 -9
View File
@@ -10,7 +10,7 @@ from generator.arrange.fretboard import pitches_for_voicing
from generator.model import beat_lengths, fraction, key_label, make_event, meter_label, rational
from generator.musicxml import load_musicxml
from generator.transposition import concert_document, shift_pitch
from generator.score import copy_source_voice, make_arranged_document, make_measure, measure_notes, reidentify, slugify, source_staff, write_json
from generator.score import copy_source_voices, make_arranged_document, make_measure, measure_notes, reidentify, slugify, source_staff, write_json
PART_TYPES = ('acoustic', 'electric', 'vocal')
FINAL_SECTION_INDEX = 6
@@ -85,6 +85,11 @@ def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dic
def arrange_document(document: dict, part_type: str) -> dict:
if part_type not in PART_TYPES:
raise ValueError(f'unknown arrangement part: {part_type}')
for measure in source_staff(document)['measures']:
specialized_staff = any(state.get('clef', {}).get('sign') in ('TAB', 'percussion') for state in [measure, *measure.get('attributes', [])])
specialized_notes = any('unpitched' in pitch or 'tablature' in pitch for note in measure_notes(measure) for pitch in note['pitches'])
if specialized_staff or specialized_notes:
raise ValueError(f'{measure["id"]}: automatic performance arranging requires a pitched non-TAB lead; percussion/TAB conversion is not qualified')
document = concert_document(document)
staff = source_staff(document)
source = staff['measures']
@@ -104,11 +109,14 @@ def arrange_document(document: dict, part_type: str) -> dict:
m['harmonies'] = [h for h in m['harmonies'] if section.section_index != FINAL_SECTION_INDEX or not h.get('chord_optional')]
for h in m['harmonies']:
h.pop('chord_optional', None)
notes = copy_source_voice(m, strip_lyrics=section.role == 'melody')['notes'] if section.role in ('vocal', 'melody') else generated_voice(m, section.role, previous[si])
source_voices = copy_source_voices(m, strip_lyrics=section.role == 'melody') if section.role in ('vocal', 'melody') else None
notes = [n for v in source_voices for n in v['notes']] if source_voices else generated_voice(m, section.role, previous[si])
if section.role == 'melody':
for note in notes:
note['pitches'] = [shift_pitch(p, 7, 12) for p in note['pitches']]
result = make_measure(f'measure-{len(output) + len(segment) + 1}', m, notes, section_label=section.label if not segment else '')
result = make_measure(f'measure-{len(output) + len(segment) + 1}', m, [] if source_voices else notes, section_label=section.label if not segment else '')
if source_voices:
result['voices'] = source_voices
if part_type != 'vocal':
result['clef'] = {'sign': 'G', 'line': 2, 'octave_change': 0}
result['transpose'] = {'diatonic': 0, 'chromatic': 0, 'octave_change': -1}
@@ -130,12 +138,13 @@ def arrange_document(document: dict, part_type: str) -> dict:
pad = fraction(destination['duration']) - fraction(pickup['duration'])
if pad < 0:
raise ValueError('pickup is longer than the preceding arrangement measure')
notes = copy_source_voice(pickup, strip_lyrics=False)['notes']
for n in notes:
n['onset'] = rational(fraction(n['onset']) + pad)
if pad:
notes.insert(0, make_event('pickup-rest', Fraction(), pad, 'rest'))
destination['voices'][0]['notes'] = notes
voices = copy_source_voices(pickup, strip_lyrics=False)
for voice in voices:
for n in voice['notes']:
n['onset'] = rational(fraction(n['onset']) + pad)
if pad:
voice['notes'].insert(0, make_event(f'pickup-rest-{voice["id"]}', Fraction(), pad, 'rest') | {'voice': voice['id']})
destination['voices'] = voices
identity_segment = [destination, *segment]
if pickup['harmonies']:
destination['harmonies'] = [h for h in destination['harmonies'] if fraction(h['onset']) < pad]
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
from xml.etree import ElementTree
from PIL import Image, ImageChops
from generator.model import ACCIDENTALS, alteration, fraction, validate_document
from generator.musicxml_writer import write_musicxml
from generator.engraving.audit import audit_roundtrip
ENGINE_VERSION = '4.7.4'
PAGE_WIDTH = 976
PAGE_HEIGHT = 484
STYLE = Path(__file__).with_name('petaluma.mss')
class EngravingError(ValueError):
pass
def qualify(document: dict) -> None:
"""Keep model support separate from the pinned engine's qualified surface."""
validate_document(document)
staff = document['staves'][0]
if len({v['id'] for m in staff['measures'] for v in m['voices']} - {'voice-arranged'}) > 4:
raise EngravingError('MuseScore 4.7.4 supports at most four voices on one staff')
event_measures = {n['id']: m['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
for span in staff.get('tuplets', []):
if event_measures[span['start']] != event_measures[span['end']]:
raise EngravingError(f'{span["id"]}: cross-bar tuplet engraving is not qualified')
if span.get('show_type', 'none') != 'none' or span.get('line_shape', 'straight') != 'straight':
raise EngravingError(f'{span["id"]}: this tuplet appearance is not qualified')
for measure in staff['measures']:
for event in (n for v in measure['voices'] for n in v['notes']):
location = f'{measure["id"]}, voice {event.get("voice")}, {event["id"]}'
if event['written']['type'] == 'maxima' or event['written'].get('dots', 0) > 4:
raise EngravingError(f'{location}: written duration exceeds the qualified engraver range')
if event.get('stem') == 'double':
raise EngravingError(f'{location}: double-stem engraving is not qualified')
for pitch in event['pitches']:
if pitch['written'] != event['written'] or pitch['duration'] != event['duration']:
raise EngravingError(f'{location}: independently sustained chord tones need separate voices for MuseScore engraving')
if 'alter' in pitch and alteration(pitch['alter']) not in ACCIDENTALS.values():
raise EngravingError(f'{location}: this microtonal alteration is not qualified for engraving')
if pitch.get('accidental_display', {}).get('name') == 'other':
raise EngravingError(f'{location}: custom accidental glyph is not qualified')
def isolated_environment(root: Path, fonts: Path) -> dict[str, str]:
env = dict(os.environ)
for name, path in (('HOME', root / 'home'), ('XDG_CONFIG_HOME', root / 'config'), ('XDG_DATA_HOME', root / 'data'), ('XDG_CACHE_HOME', root / 'cache'), ('XDG_RUNTIME_DIR', root / 'runtime')):
path.mkdir(mode=0o700, parents=True, exist_ok=True)
env[name] = str(path)
config = ElementTree.Element('fontconfig')
ElementTree.SubElement(config, 'include', {'ignore_missing': 'yes'}).text = '/etc/fonts/fonts.conf'
ElementTree.SubElement(config, 'dir').text = str(fonts.resolve())
ElementTree.SubElement(config, 'cachedir').text = str(root / 'font-cache')
fontconfig = root / 'fonts.conf'
ElementTree.ElementTree(config).write(fontconfig, encoding='utf-8', xml_declaration=True)
env.update({'QT_QPA_PLATFORM': 'offscreen', 'QT_QPA_PLATFORMTHEME': '', 'FONTCONFIG_FILE': str(fontconfig), 'QT_STYLE_OVERRIDE': 'Fusion'})
return env
def run_engine(executable: str, arguments: list[str], env: dict) -> str:
try:
result = subprocess.run([executable, '-platform', 'offscreen', *arguments], env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180, check=False)
except (OSError, subprocess.TimeoutExpired) as error:
raise EngravingError(f'MuseScore export failed: {error}') from error
if result.returncode:
raise EngravingError(f'MuseScore export exited {result.returncode}: {result.stdout[-4000:]}')
return result.stdout
def check_page(path: Path) -> None:
with Image.open(path) as image:
image.load()
if image.format != 'PNG' or image.size != (PAGE_WIDTH, PAGE_HEIGHT):
raise EngravingError(f'{path.name}: expected a {PAGE_WIDTH}x{PAGE_HEIGHT} PNG page')
white = Image.new('RGBA', image.size, 'white')
white.alpha_composite(image.convert('RGBA'))
ink = ImageChops.difference(white.convert('RGB'), Image.new('RGB', image.size, 'white')).convert('L').point(lambda x: 255 if x > 32 else 0)
bounds = ink.getbbox()
if not bounds:
raise EngravingError(f'{path.name}: engraver produced a blank page')
if bounds[0] < 4 or bounds[1] < 4 or bounds[2] > PAGE_WIDTH - 4 or bounds[3] > PAGE_HEIGHT - 4:
raise EngravingError(f'{path.name}: notation reaches the page edge; refusing potentially clipped output')
def engrave_tree(root: Path, fonts: Path) -> None:
paths = sorted(root.rglob('*.json'))
if not paths:
return
executable = os.environ.get('VAMP_MUSESCORE') or shutil.which('mscore') or shutil.which('musescore')
if not executable:
raise EngravingError(f'MuseScore Studio {ENGINE_VERSION} is required at build time (set VAMP_MUSESCORE)')
with tempfile.TemporaryDirectory(prefix='vamp-engraving-') as temporary:
work = Path(temporary)
env = isolated_environment(work, fonts)
version = run_engine(executable, ['--version'], env)
if not re.search(r'\b4\.7\.4\b', version):
raise EngravingError(f'expected MuseScore Studio {ENGINE_VERSION}, received {version.strip()!r}')
for index, path in enumerate(paths):
document = json.loads(path.read_text(encoding='utf-8'))
try:
qualify(document)
source = work / f'score-{index}.musicxml'
write_musicxml(source, document, engine_voices=True)
output = work / f'score-{index}.png'
audit = work / f'audit-{index}.musicxml'
job = work / 'job.json'
job.write_text(json.dumps([{'in': str(source), 'out': [str(output), str(audit)]}]), encoding='utf-8')
run_engine(executable, ['-S', str(STYLE.resolve()), '-r', '96', '-j', str(job)], env)
audit_roundtrip(source, audit)
pages = sorted(work.glob(f'score-{index}-*.png'), key=lambda p: int(p.stem.rsplit('-', 1)[1]))
if not pages:
raise EngravingError('engraver produced no pages')
destination = path.with_suffix('.pages')
if destination.exists():
shutil.rmtree(destination)
destination.mkdir()
entries = []
for number, page in enumerate(pages, 1):
check_page(page)
name = f'{number:04}.png'
target = destination / name
shutil.copyfile(page, target)
entries.append({'path': destination.name + '/' + name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
music = {k: v for k, v in document.items() if k != 'schema_version'}
bundle = {'schema_version': 3, 'music': music, 'presentation': {'engine': 'MuseScore Studio', 'engine_version': ENGINE_VERSION, 'style_sha256': hashlib.sha256(STYLE.read_bytes()).hexdigest(), 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
except ValueError as error:
raise EngravingError(f'{path.relative_to(root)}: {error}') from error
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from pathlib import Path
from generator import harmony
from generator.musicxml import load_musicxml
def same(left, right, location: str) -> None:
if left != right:
raise ValueError(f'{location}: engraver changed musical content ({left!r} -> {right!r})')
def event_positions(staff: dict) -> dict:
return {event['id']: (mi, vi, ni) for mi, measure in enumerate(staff['measures']) for vi, voice in enumerate(measure['voices']) for ni, event in enumerate(voice['notes'])}
def tuplet_members(staff: dict, identifier: str) -> list:
positions = event_positions(staff)
return [positions[event['id']] for measure in staff['measures'] for voice in measure['voices'] for event in voice['notes'] if identifier in event.get('tuplet_ids', [])]
def audit_roundtrip(source: Path, output: Path) -> None:
before = load_musicxml(source)['staves'][0]
after = load_musicxml(output)['staves'][0]
before_positions, after_positions = event_positions(before), event_positions(after)
before_instruments = {i['id']: i for i in before.get('instruments', [])}
after_instruments = {i['id']: i for i in after.get('instruments', [])}
same(len(before['measures']), len(after['measures']), 'measure count')
for original, rendered in zip(before['measures'], after['measures']):
location = original['id']
for field in ('duration', 'key', 'meter', 'clef', 'transpose', 'attributes'):
same(original[field], rendered[field], f'{location} {field}')
same({'capo': 0, **original['staff_details']}, {'capo': 0, **rendered['staff_details']}, f'{location} staff details')
for side, style in original.get('barlines', {}).items():
same(style, rendered.get('barlines', {}).get(side, 'regular'), f'{location} {side} barline')
same(original.get('tempos', []), rendered.get('tempos', []), f'{location} tempos')
same(original.get('section_label', ''), rendered.get('section_label', ''), f'{location} section label')
same(len(original['harmonies']), len(rendered['harmonies']), f'{location} harmony count')
for a, b in zip(original['harmonies'], rendered['harmonies']):
for field in ('root', 'bass', 'onset'):
same(a.get(field), b.get(field), f'{location} harmony {field}')
same(harmony.degrees(a), harmony.degrees(b), f'{location} harmony degrees')
same(len(original['voices']), len(rendered['voices']), f'{location} voice count')
for first, second in zip(original['voices'], rendered['voices']):
same(len(first['notes']), len(second['notes']), f'{location} voice {first["id"]} event count')
for a, b in zip(first['notes'], second['notes']):
event_location = f'{location}, voice {first["id"]}, {a["id"]}'
for field in ('kind', 'onset', 'duration', 'written', 'hidden'):
same(a[field], b[field], f'{event_location} {field}')
for field in ('grace', 'cue', 'measure_rest', 'rest_position', 'lyrics'):
same(a.get(field), b.get(field), f'{event_location} {field}')
for field in ('stem', 'beams', 'notehead'):
if field in a:
same(a[field], b.get(field), f'{event_location} explicit {field}')
same(len(a['pitches']), len(b['pitches']), f'{event_location} chord tone count')
for p, q in zip(a['pitches'], b['pitches']):
if p.get('instrument') and 'unpitched' in p:
first_instrument = before_instruments[p['instrument']]
second_instrument = after_instruments.get(q.get('instrument'), {})
same(first_instrument.get('midi', {}).get('midi-unpitched'), second_instrument.get('midi', {}).get('midi-unpitched'), f'{event_location} percussion instrument')
for field in ('letter', 'alter', 'octave', 'duration', 'written', 'unpitched', 'tablature', 'notehead', 'cue'):
same(p.get(field), q.get(field), f'{event_location} pitch {field}')
for field in ('stem', 'print'):
if field in p:
same(p[field], q.get(field), f'{event_location} pitch {field}')
for field in ('tie_from', 'tie_to'):
same(bool(p.get(field)), bool(q.get(field)), f'{event_location} {field}')
if display := p.get('accidental_display'):
other = q.get('accidental_display', {})
same(display.get('name'), other.get('name'), f'{event_location} accidental')
for field in ('parentheses', 'bracket'):
same(display.get(field, 'no'), other.get(field, 'no'), f'{event_location} accidental {field}')
for field in ('cautionary', 'editorial'):
if field in display:
same(display[field], other.get(field, 'no'), f'{event_location} accidental {field}')
if tremolo := a.get('tremolo'):
other = b.get('tremolo', {})
for field in ('type', 'marks'):
same(tremolo[field], other.get(field), f'{event_location} tremolo {field}')
if 'partner' in tremolo:
same(before_positions.get(tremolo['partner']), after_positions.get(other.get('partner')), f'{event_location} tremolo partner')
same(len(before.get('tuplets', [])), len(after.get('tuplets', [])), 'tuplet count')
for a, b in zip(before.get('tuplets', []), after.get('tuplets', [])):
for field in ('note_count', 'space_count', 'depth', 'show_number', 'show_type'):
same(a.get(field), b.get(field), f'{a["id"]} {field}')
if a['bracket'] != 'auto':
same(a['bracket'], b['bracket'], f'{a["id"]} bracket')
same(tuplet_members(before, a['id']), tuplet_members(after, b['id']), f'{a["id"]} members')
for endpoint in ('start', 'end'):
same(before_positions[a[endpoint]], after_positions[b[endpoint]], f'{a["id"]} {endpoint}')
for field in ('tuplet_actual', 'tuplet_normal'):
if a.get(field, {}).get('type'):
same(a[field], b.get(field), f'{a["id"]} {field}')
same([{k: v for k, v in s.items() if k != 'id'} for s in before.get('octave_shifts', [])],
[{k: v for k, v in s.items() if k != 'id'} for s in after.get('octave_shifts', [])], 'octave spans')
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<museScore version="4.70">
<Style>
<pageWidth>10.1666666667</pageWidth>
<pageHeight>5.0416666667</pageHeight>
<pagePrintableWidth>9.5833333333</pagePrintableWidth>
<pageEvenLeftMargin>0.2916666667</pageEvenLeftMargin>
<pageOddLeftMargin>0.2916666667</pageOddLeftMargin>
<pageEvenTopMargin>0.1875</pageEvenTopMargin>
<pageOddTopMargin>0.1875</pageOddTopMargin>
<pageEvenBottomMargin>0.1875</pageEvenBottomMargin>
<pageOddBottomMargin>0.1875</pageOddBottomMargin>
<pageTwosided>0</pageTwosided>
<Spatium>3.175</Spatium>
<musicalSymbolFont>Petaluma</musicalSymbolFont>
<musicalTextFont>Petaluma Text</musicalTextFont>
<defaultFontFace>Petaluma Script</defaultFontFace>
<titleFontFace>Petaluma Script</titleFontFace>
<titleFontSize>18</titleFontSize>
<composerFontFace>Petaluma Script</composerFontFace>
<composerFontSize>10</composerFontSize>
<lyricsOddFontFace>Petaluma Script</lyricsOddFontFace>
<lyricsEvenFontFace>Petaluma Script</lyricsEvenFontFace>
<harmonyFontFace>Petaluma Script</harmonyFontFace>
<harmonyFontSize>10</harmonyFontSize>
<rehearsalMarkAlign>left,baseline</rehearsalMarkAlign>
<rehearsalMarkFrameType>0</rehearsalMarkFrameType>
<enableVerticalSpread>0</enableVerticalSpread>
<minSystemDistance>6</minSystemDistance>
<maxSystemDistance>6</maxSystemDistance>
<showPageNumber>1</showPageNumber>
<showPageNumberOne>1</showPageNumberOne>
</Style>
</museScore>
+7 -6
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from generator.model import NATURAL, parse_pitch_name, pitch_name
from generator.model import NATURAL, alteration, parse_pitch_name, pitch_name
# Semantic degrees are independent of guitar voicing omissions.
KINDS = {
@@ -31,8 +31,8 @@ def degrees(harmony: dict) -> dict[int, int]:
raise ValueError(f'unsupported harmony kind: {kind}')
result = KINDS[kind].copy()
for change in harmony.get('degrees', []):
degree, alter, operation = change['value'], change['alter'], change['type']
if type(degree) is not int or degree < 1 or type(alter) is not int:
degree, alter, operation = change['value'], alteration(change['alter']), change['type']
if type(degree) is not int or degree < 1:
raise ValueError('invalid harmony degree')
if operation == 'subtract':
if degree not in result:
@@ -59,10 +59,10 @@ def pitch_classes(harmony: dict) -> tuple[int, ...]:
if harmony['kind'] == 'none':
return ()
root = harmony['root']
root_pc = NATURAL[root['letter']] + root['alter']
root_pc = NATURAL[root['letter']] + alteration(root['alter'])
values = [(root_pc + degree_semitones(d, a)) % 12 for d, a in degrees(harmony).items()]
if bass := harmony.get('bass'):
values.append((NATURAL[bass['letter']] + bass['alter']) % 12)
values.append((NATURAL[bass['letter']] + alteration(bass['alter'])) % 12)
return tuple(dict.fromkeys(values))
def symbol(harmony: dict) -> str:
@@ -71,7 +71,8 @@ def symbol(harmony: dict) -> str:
suffix = harmony.get('kind_text', SUFFIXES.get(harmony['kind'], harmony['kind']))
if 'kind_text' not in harmony:
for d in harmony.get('degrees', []):
sign = '#' * max(d['alter'], 0) + 'b' * max(-d['alter'], 0)
amount = alteration(d['alter'])
sign = '#' * max(int(amount), 0) + 'b' * max(int(-amount), 0) if amount.denominator == 1 else f'({amount})'
suffix += ('no' if d['type'] == 'subtract' else 'add' if d['type'] == 'add' and not sign else '') + sign + str(d['value'])
result = pitch_name(harmony['root']) + suffix
if bass := harmony.get('bass'):
+59 -25
View File
@@ -4,13 +4,20 @@ import copy
from fractions import Fraction
from typing import Any
SCHEMA_VERSION = 2
SCHEMA_VERSION = 3
MAX_INTEGER = (1 << 63) - 1
NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th')
TYPE_LENGTHS = {name: Fraction(32, 2 ** index) for index, name in enumerate(NOTE_TYPES)}
NATURAL = {'c': 0, 'd': 2, 'e': 4, 'f': 5, 'g': 7, 'a': 9, 'b': 11}
LETTERS = 'cdefgab'
MODE_STEPS = {'major': 0, 'ionian': 0, 'dorian': 1, 'phrygian': 2, 'lydian': 3, 'mixolydian': 4, 'minor': 5, 'aeolian': 5, 'locrian': 6}
ACCIDENTALS = {'natural': Fraction(0), 'sharp': Fraction(1), 'flat': Fraction(-1), 'double-sharp': Fraction(2), 'sharp-sharp': Fraction(2), 'flat-flat': Fraction(-2), 'triple-sharp': Fraction(3), 'triple-flat': Fraction(-3), 'natural-sharp': Fraction(1), 'natural-flat': Fraction(-1), 'quarter-sharp': Fraction(1, 2), 'quarter-flat': Fraction(-1, 2), 'three-quarters-sharp': Fraction(3, 2), 'three-quarters-flat': Fraction(-3, 2)}
def accidental_name(value: Fraction) -> str:
for name, amount in ACCIDENTALS.items():
if amount == value:
return name
return 'other'
def rational(value: Fraction | int) -> dict[str, int]:
value = Fraction(value)
@@ -52,21 +59,25 @@ def metric_length(written: dict) -> Fraction:
written_length(written['normal'])
return written_length(written) * ratio
def pitch_number(pitch: dict) -> int:
def alteration(value: int | dict) -> Fraction:
return Fraction(value) if type(value) is int else fraction(value)
def pitch_number(pitch: dict) -> Fraction:
letter = pitch['letter'].lower()
alter, octave = pitch.get('alter', 0), pitch['octave']
if letter not in NATURAL or type(alter) is not int or type(octave) is not int or not -3 <= alter <= 3 or not -4 <= octave <= 16:
alter, octave = alteration(pitch.get('alter', 0)), pitch['octave']
if letter not in NATURAL or type(octave) is not int or not -4 <= octave <= 16:
raise ValueError('invalid spelled pitch')
return 12 * (octave + 1) + NATURAL[letter] + alter
def pitch_name(pitch: dict) -> str:
alter = pitch.get('alter', 0)
return pitch['letter'].upper() + ('#' * alter if alter > 0 else 'b' * -alter)
alter = alteration(pitch.get('alter', 0))
suffix = ('#' * int(alter) if alter > 0 else 'b' * int(-alter)) if alter.denominator == 1 else f'({alter:+} semitones)'
return pitch['letter'].upper() + suffix
def parse_pitch_name(name: str) -> dict:
if not name or name[0].lower() not in NATURAL or any(c not in '#b' for c in name[1:]):
raise ValueError(f'invalid pitch name: {name!r}')
return {'letter': name[0].lower(), 'alter': name[1:].count('#') - name[1:].count('b')}
return {'letter': name[0].lower(), 'alter': rational(name[1:].count('#') - name[1:].count('b'))}
def key_from_fifths(fifths: int, mode: str = 'major') -> dict:
if mode not in MODE_STEPS and mode != 'none':
@@ -79,8 +90,8 @@ def key_from_fifths(fifths: int, mode: str = 'major') -> dict:
order = 'fcgdaeb' if fifths > 0 else 'beadgcf'
for i in range(abs(fifths)):
signature[LETTERS.index(order[i % 7])] += 1 if fifths > 0 else -1
tonic = {'letter': letter, 'alter': signature[LETTERS.index(letter)]}
return {'fifths': fifths, 'mode': mode, 'tonic': tonic if mode != 'none' else None, 'alterations': signature}
tonic = {'letter': letter, 'alter': rational(signature[LETTERS.index(letter)])}
return {'fifths': fifths, 'mode': mode, 'tonic': tonic if mode != 'none' else None, 'alterations': [rational(v) for v in signature]}
def key_label(key: dict) -> str:
if not key.get('tonic'):
@@ -125,6 +136,8 @@ def make_event(identifier: str, onset: Fraction, duration: Fraction, kind: str,
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)})
if 'alter' in event['pitches'][-1]:
event['pitches'][-1]['alter'] = rational(alteration(p['alter']))
return event
def validate_document(document: dict) -> None:
@@ -133,32 +146,40 @@ def validate_document(document: dict) -> None:
staves = document.get('staves', [])
if len(staves) != 1:
raise ValueError('exactly one staff is currently supported')
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, Fraction, Fraction]] = {}
tones: dict[str, tuple[dict, Fraction, Fraction, str]] = {}
ownership: dict[str, str] = {}
absolute = Fraction()
for measure in staves[0]['measures']:
length = fraction(measure['duration'])
if length <= 0:
raise ValueError('measure duration must be positive')
voices = measure.get('voices', [])
if len(voices) != 1:
raise ValueError('exactly one rhythmic voice is currently supported')
previous_end = Fraction()
for note in voices[0]['notes']:
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']: Fraction() for v in voices}
for voice, note in ((v, n) for v in voices for n in v['notes']):
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_end:
if duration == 0 or onset < previous_ends[voice['id']]:
raise ValueError('overlapping events in one rhythmic voice')
previous_end = onset + duration
previous_ends[voice['id']] = onset + duration
elif duration != 0:
raise ValueError('grace notes must not advance metric time')
expected = metric_length(note['written'])
@@ -169,13 +190,20 @@ def validate_document(document: dict) -> None:
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', []):
pitch_number(p)
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)
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)
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')
@@ -185,17 +213,21 @@ def validate_document(document: dict) -> None:
for state in [measure, *measure.get('attributes', [])]:
if 'meter' in state:
validate_meter(state['meter'])
if 'key' in state and (len(state['key']['alterations']) != 7 or any(type(a) is not int or not -3 <= a <= 3 for a in state['key']['alterations'])):
raise ValueError('key alterations must contain seven integers from -3 through 3')
if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C') or not 1 <= state['clef']['line'] <= 5 or not -3 <= state['clef']['octave_change'] <= 3):
raise ValueError('invalid pitched clef')
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', 'TAB', '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) in tones.items():
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, _ = tones[target]
if other.get('tie_from') != identifier or pitch_number(p) != pitch_number(other) or next_start != start + duration:
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 source := p.get('tie_from'):
if source not in tones or tones[source][0].get('tie_to') != identifier:
@@ -208,6 +240,8 @@ def validate_document(document: dict) -> None:
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'])
+156 -75
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from defusedxml import ElementTree
from generator import harmony
from generator.model import (SCHEMA_VERSION, TYPE_LENGTHS, fraction, key_from_fifths, key_label, meter_label, meter_length, pitch_number, rational, validate_document, written_for, written_length, )
from generator.model import (ACCIDENTALS, SCHEMA_VERSION, TYPE_LENGTHS, alteration, fraction, key_from_fifths, key_label, meter_label, meter_length, pitch_number, rational, validate_document, written_for, written_length, )
from generator.styles import canonical_style
class MusicXMLError(ValueError):
@@ -27,16 +27,17 @@ class Importer:
self.clef = {'sign': 'G', 'line': 2, 'octave_change': 0}
self.transpose = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0}
self.sequence = 0
self.harmony_sequence = 0
self.tuplet_sequence = 0
self.active_tuplets: dict[str, dict] = {}
self.tuplets: list[dict] = []
self.open_ties: dict[int, dict] = {}
self.open_octaves: dict[str, dict] = {}
self.octaves: list[dict] = []
self.voice_id = None
self.lyric_number = None
self.voice_id = '1'
self.staff_details = {'lines': 5, 'tunings': []}
self.absolute = Fraction()
self.tremolo_start = None
self.tremolo_starts = {}
def fail(self, text: str):
raise MusicXMLError(f'{self.location}: {text}')
def children(self, node, allowed: set[str]) -> None:
@@ -65,12 +66,11 @@ class Importer:
self.fail('multiple staves are not supported')
if voice is None:
return
if self.voice_id is None:
self.voice_id = voice
if voice != self.voice_id:
self.fail('multiple rhythmic voices are not supported')
if not voice.strip():
self.fail('empty voice identifier')
self.voice_id = voice
def attributes(self, node, onset: Fraction) -> dict:
self.children(node, {'divisions', 'key', 'time', 'clef', 'staves', 'transpose', 'instruments'})
self.children(node, {'divisions', 'key', 'time', 'clef', 'staves', 'transpose', 'instruments', 'staff-details'})
result = {'onset': rational(onset)}
for item in node:
if item.get('number', '1') != '1':
@@ -86,7 +86,7 @@ class Importer:
if item.find('fifths') is not None:
self.key = key_from_fifths(self.number(item.findtext('fifths'), integer=True), item.findtext('mode', 'major'))
else:
self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [0] * 7, 'custom': True}
self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [rational(0) for _ in range(7)], 'custom': True, 'steps': []}
pending = None
for field in item:
if field.tag == 'key-step':
@@ -96,10 +96,19 @@ class Importer:
elif field.tag == 'key-alter':
if pending is None:
self.fail('key alteration without a step')
self.key['alterations']['cdefgab'.index(pending)] = self.number(field.text, integer=True)
amount = rational(self.number(field.text))
self.key['alterations']['cdefgab'.index(pending)] = amount
self.key['steps'].append({'letter': pending, 'alter': amount})
pending = None
elif field.tag in ('key-octave', 'key-accidental'):
self.fail(f'<{field.tag}> requires unsupported custom key placement')
elif field.tag == 'key-accidental':
if not self.key['steps']:
self.fail('key accidental without a preceding step')
self.key['steps'][-1]['accidental'] = field.text
elif field.tag == 'key-octave':
number = self.number(field.get('number'), integer=True, positive=True)
if number > len(self.key['steps']):
self.fail('key octave refers to an absent step')
self.key['steps'][number - 1]['octave'] = self.number(field.text, integer=True)
if pending is not None:
self.fail('custom key step without alteration')
if item.find('cancel') is not None:
@@ -132,13 +141,20 @@ class Importer:
self.attrs(item, {'number', 'additional', 'size', 'after-barline', 'print-object'})
self.children(item, {'sign', 'line', 'clef-octave-change'})
sign = item.findtext('sign')
if sign not in ('G', 'F', 'C'):
self.fail(f'unsupported clef {sign!r}; only pitched G/F/C staves are supported')
line = self.number(item.findtext('line', {'G': '2', 'F': '4', 'C': '3'}[sign]), integer=True)
if not 1 <= line <= 5:
self.fail('clef line must be between 1 and 5')
if sign not in ('G', 'F', 'C', 'percussion', 'TAB', 'none'):
self.fail(f'unsupported clef {sign!r}')
line = self.number(item.findtext('line', {'G': '2', 'F': '4'}.get(sign, '3')), integer=True)
if not 1 <= line <= 6:
self.fail('clef line must be between 1 and 6')
self.clef = {'sign': sign, 'line': line, 'octave_change': self.number(item.findtext('clef-octave-change', '0'), integer=True)}
result['clef'] = copy.deepcopy(self.clef)
elif item.tag == 'staff-details':
self.children(item, {'staff-lines', 'staff-tuning', 'capo'})
self.staff_details = {'lines': self.number(item.findtext('staff-lines', '5'), integer=True, positive=True), 'tunings': [], 'capo': self.number(item.findtext('capo', '0'), integer=True)}
for tuning in item.findall('staff-tuning'):
self.children(tuning, {'tuning-step', 'tuning-alter', 'tuning-octave'})
self.staff_details['tunings'].append({'line': self.number(tuning.get('line'), integer=True, positive=True), 'letter': tuning.findtext('tuning-step', '').lower(), 'alter': rational(self.number(tuning.findtext('tuning-alter', '0'))), 'octave': self.number(tuning.findtext('tuning-octave'), integer=True)})
result['staff_details'] = copy.deepcopy(self.staff_details)
elif item.tag == 'transpose':
self.children(item, {'diatonic', 'chromatic', 'octave-change'})
self.transpose = {key: self.number(item.findtext(tag, '0'), integer=True) for key, tag in (('diatonic', 'diatonic'), ('chromatic', 'chromatic'), ('octave_change', 'octave-change'))}
@@ -157,7 +173,8 @@ class Importer:
self.fail('stacked harmonies require separate harmony events')
self.identity(node)
kind = node.find('kind')
result = {'id': f'harmony-{self.sequence}-{cursor}', 'onset': rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []}
self.harmony_sequence += 1
result = {'id': f'harmony-{self.harmony_sequence}', 'onset': rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []}
self.attrs(kind, {'text', 'use-symbols', 'stack-degrees', 'parentheses-degrees', 'bracket-degrees'})
if 'text' in kind.attrib:
result['kind_text'] = kind.get('text')
@@ -171,24 +188,23 @@ class Importer:
step = item.findtext(tag + '-step', '').lower()
if len(step) != 1 or step not in 'cdefgab':
self.fail('invalid harmony root/bass')
result[tag] = {'letter': step, 'alter': self.number(item.findtext(tag + '-alter', '0'), integer=True)}
result[tag] = {'letter': step, 'alter': rational(self.number(item.findtext(tag + '-alter', '0')))}
for degree in node.findall('degree'):
self.children(degree, {'degree-value', 'degree-alter', 'degree-type'})
value = self.number(degree.findtext('degree-value'), integer=True, positive=True)
alteration = self.number(degree.findtext('degree-alter', '0'), integer=True)
degree_alter = self.number(degree.findtext('degree-alter', '0'))
operation = degree.findtext('degree-type')
# MusicXML alterations are relative to the kind; additions use dominant intervals.
if operation == 'alter':
alteration += harmony.KINDS.get(result['kind'], {}).get(value, 0)
degree_alter += harmony.KINDS.get(result['kind'], {}).get(value, 0)
elif operation == 'add' and (value - 1) % 7 == 6:
alteration -= 1
elif operation == 'subtract' and alteration:
degree_alter -= 1
elif operation == 'subtract' and degree_alter:
self.fail('cannot subtract a degree different from the one in the chord kind')
result['degrees'].append({'value': value, 'alter': alteration, 'type': operation})
result['degrees'].append({'value': value, 'alter': rational(degree_alter), 'type': operation})
chord_degrees = harmony.degrees(result)
if 'kind_text' in result and result['kind'] != 'none':
from generator.model import pitch_name
label = pitch_name(result['root']) + result['kind_text']
label = 'C' + result['kind_text']
try:
displayed = harmony.degrees(harmony.parse_symbol(label))
except ValueError:
@@ -208,12 +224,12 @@ class Importer:
letter_index = 'cdefgab'.index(root['letter']) + degree - 1
bass_letter = 'cdefgab'[letter_index % 7]
from generator.model import NATURAL
target = NATURAL[root['letter']] + root['alter'] + harmony.degree_semitones(degree, chord_degrees[degree])
result['bass'] = {'letter': bass_letter, 'alter': target - (12 * (letter_index // 7) + NATURAL[bass_letter])}
target = NATURAL[root['letter']] + alteration(root['alter']) + harmony.degree_semitones(degree, chord_degrees[degree])
result['bass'] = {'letter': bass_letter, 'alter': rational(target - (12 * (letter_index // 7) + NATURAL[bass_letter]))}
result['symbol'] = harmony.symbol(result)
return result
def direction(self, node, cursor: Fraction, measure: dict, optional: list[Fraction]) -> None:
self.attrs(node, {'print-object', 'directive'})
self.attrs(node, {'print-object', 'directive', 'system'})
self.children(node, {'direction-type', 'offset', 'staff', 'voice', 'sound'})
onset = self.offset(node, cursor)
for group in node.findall('direction-type'):
@@ -265,18 +281,19 @@ class Importer:
self.attrs(node, {'print-object', 'print-dot', 'print-spacing', 'default-x', 'dynamics', 'end-dynamics'})
if node.get('dynamics') or node.get('end-dynamics'):
self.fail('note dynamics are not supported')
self.children(node, {'pitch', 'rest', 'chord', 'duration', 'voice', 'type', 'dot', 'accidental', 'time-modification', 'stem', 'staff', 'beam', 'notations', 'lyric', 'tie', 'grace', 'cue', 'notehead'})
self.children(node, {'pitch', 'unpitched', 'instrument', 'rest', 'chord', 'duration', 'voice', 'type', 'dot', 'accidental', 'time-modification', 'stem', 'staff', 'beam', 'notations', 'lyric', 'tie', 'grace', 'cue', 'notehead'})
for tag in ('pitch', 'rest', 'chord', 'duration', 'voice', 'type', 'accidental', 'time-modification', 'stem', 'staff', 'grace', 'cue', 'notehead'):
if len(node.findall(tag)) > 1:
self.fail(f'duplicate <{tag}> in <note>')
if node.find('rest') is not None and node.find('pitch') is not None:
self.fail('note cannot contain both pitch and rest')
self.identity(node)
voice = self.voice_id
is_chord = node.find('chord') is not None
grace = node.find('grace')
duration = Fraction() if grace is not None else self.number(node.findtext('duration'), positive=True) / self.divisions
onset = fraction(previous['onset']) if is_chord and previous else cursor
if is_chord and (previous is None or previous['kind'] != 'note' or duration > fraction(previous['duration'])):
if is_chord and (previous is None or previous['voice'] != voice or previous['kind'] != 'note' or duration > fraction(previous['duration'])):
self.fail('invalid chord tone or chord tone longer than its anchor')
if is_chord and (grace is not None) != ('grace' in previous):
self.fail('chord tones must share grace/regular timing')
@@ -301,16 +318,17 @@ 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}', 'kind': 'rest' if node.find('rest') is not None else 'note', 'onset': rational(onset), 'duration': 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': rational(onset), 'duration': rational(duration), 'written': written, 'hidden': node.get('print-object') == 'no', 'pitches': []}
if is_chord:
note = previous
if grace is not None:
if grace is not None and not is_chord:
self.attrs(grace, {'slash', 'steal-time-previous', 'steal-time-following', 'make-time'})
note['grace'] = {'slash': grace.get('slash') == 'yes'}
for name in ('steal-time-previous', 'steal-time-following', 'make-time'):
if name in grace.attrib:
note['grace'][name] = rational(self.number(grace.get(name), positive=True))
if node.find('cue') is not None:
value = self.number(grace.get(name), positive=True)
note['grace'][name] = rational(value / self.divisions if name == 'make-time' else value)
if node.find('cue') is not None and not is_chord:
note['cue'] = True
if node.find('rest') is not None:
self.children(node.find('rest'), {'display-step', 'display-octave'})
@@ -320,19 +338,38 @@ 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')
if pitch_node is None:
self.fail('note has neither pitch nor rest')
self.children(pitch_node, {'step', 'alter', 'octave'})
p = {'id': f'{note["id"]}-p{len(note["pitches"]) + 1}', 'letter': pitch_node.findtext('step', '').lower(), 'alter': self.number(pitch_node.findtext('alter', '0'), integer=True), 'octave': self.number(pitch_node.findtext('octave'), integer=True), 'duration': rational(duration), 'written': written}
value = pitch_number(p)
p = {'id': f'{note["id"]}-p{len(note["pitches"]) + 1}', 'duration': 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': rational(self.number(pitch_node.findtext('alter', '0'))), 'octave': self.number(pitch_node.findtext('octave'), integer=True)})
pitch_number(p)
value = (voice, p['letter'], alteration(p['alter']), p['octave'])
elif node.find('unpitched') is not None:
self.children(node.find('unpitched'), {'display-step', 'display-octave'})
p['unpitched'] = {'letter': node.findtext('unpitched/display-step', 'B').lower(), 'octave': self.number(node.findtext('unpitched/display-octave', '4'), integer=True)}
p['instrument'] = node.find('instrument').get('id') if node.find('instrument') is not None else None
value = (voice, p['instrument'], p['unpitched']['letter'], p['unpitched']['octave'])
else:
self.fail('note has neither pitch, unpitched position, nor rest')
if node.find('instrument') is not None:
self.attrs(node.find('instrument'), {'id'})
p['instrument'] = node.find('instrument').get('id')
for name in ('print-object', 'print-dot', 'print-spacing'):
if name in node.attrib:
p.setdefault('print', {})[name] = node.get(name)
if node.find('cue') is not None:
p['cue'] = True
if node.find('stem') is not None:
p['stem'] = node.findtext('stem')
p['_absolute'] = self.absolute + onset
accidental = node.find('accidental')
if accidental is not None:
self.attrs(accidental, {'cautionary', 'editorial', 'parentheses', 'bracket', 'size', 'smufl'})
allowed = {'natural': 0, 'sharp': 1, 'flat': -1, 'double-sharp': 2, 'sharp-sharp': 2, 'flat-flat': -2, 'triple-sharp': 3, 'triple-flat': -3, 'natural-sharp': 1, 'natural-flat': -1}
if accidental.text not in allowed or allowed[accidental.text] != p['alter']:
self.fail('unsupported or contradictory accidental')
p['accidental_display'] = {'name': accidental.text, 'parentheses': accidental.get('parentheses') == 'yes' or accidental.get('cautionary') == 'yes', 'bracket': accidental.get('bracket') == 'yes'}
if 'unpitched' in p or accidental.text in ACCIDENTALS and ACCIDENTALS[accidental.text] != alteration(p['alter']):
self.fail('contradictory accidental')
p['accidental_display'] = {'name': accidental.text, **{k: v for k, v in accidental.attrib.items() if k not in LAYOUT}}
if node.find('notehead') is not None:
p['notehead'] = {'name': node.findtext('notehead'), **dict(node.find('notehead').attrib)}
ties = {t.get('type') for t in node.findall('tie')}
notations = node.find('notations')
if notations is not None:
@@ -361,11 +398,12 @@ class Importer:
note['stem'] = stem
head = node.findtext('notehead')
if head is not None:
if head not in ('normal', 'slash'):
self.fail(f'unsupported notehead: {head}')
note['notehead'] = head
beams = []
for beam in node.findall('beam'):
self.attrs(beam, {'number', 'repeater'})
if beam.get('repeater') == 'yes':
self.fail('repeater beams are not supported')
level = self.number(beam.get('number', '1'), integer=True)
if not 1 <= level <= 8 or beam.text not in ('begin', 'continue', 'end', 'forward hook', 'backward hook'):
self.fail('invalid beam')
@@ -375,10 +413,8 @@ class Importer:
for lyric in node.findall('lyric'):
self.children(lyric, {'syllabic', 'text', 'elision', 'extend', 'end-line', 'end-paragraph'})
number = lyric.get('number', '1')
if self.lyric_number is None:
self.lyric_number = number
if self.lyric_number != number or 'lyric' in note:
self.fail('only one lyric verse is supported')
if any(v['number'] == number for v in note.get('lyrics', [])):
self.fail('duplicate lyric verse on one event')
pieces = []
for item in lyric:
if item.tag in ('text', 'elision'):
@@ -386,16 +422,25 @@ class Importer:
syllabic = lyric.findtext('syllabic', 'single')
if syllabic not in ('single', 'begin', 'middle', 'end'):
self.fail('invalid lyric syllabification')
note['lyric'] = ''.join(pieces) + ('-' if syllabic in ('begin', 'middle') else '')
note['lyric_notation'] = {'text': ''.join(pieces), 'syllabic': syllabic}
verse = {'number': number, 'syllables': [{'kind': child.tag, 'text': child.text or '', 'attributes': dict(child.attrib)} for child in lyric]}
note.setdefault('lyrics', []).append(verse)
if len(note['lyrics']) == 1:
note['lyric'] = ''.join(pieces) + ('-' if syllabic in ('begin', 'middle') else '')
note['lyric_notation'] = {'text': ''.join(pieces), 'syllabic': syllabic}
if lyric.find('extend') is not None:
note['lyric_notation']['extend'] = lyric.find('extend').get('type', 'start')
stops = []
for notations in node.findall('notations'):
self.children(notations, {'tied', 'tuplet', 'ornaments'})
self.children(notations, {'tied', 'tuplet', 'ornaments', 'technical'})
for item in notations:
if item.tag == 'technical':
self.children(item, {'string', 'fret'})
if note['kind'] != 'note' or item.find('string') is None or item.find('fret') is None:
self.fail('tablature requires a string and fret on a note')
note['pitches'][-1]['tablature'] = {'string': self.number(item.findtext('string'), integer=True, positive=True), 'fret': self.number(item.findtext('fret'), integer=True)}
if item.tag == 'tuplet' and not is_chord:
number, action = item.get('number', '1'), item.get('type')
number, action = (voice, item.get('number', '1')), item.get('type')
self.attrs(item, {'number', 'type', 'bracket', 'show-number', 'show-type', 'line-shape'})
self.children(item, {'tuplet-actual', 'tuplet-normal'})
if action == 'start':
if number in self.active_tuplets:
@@ -403,9 +448,16 @@ class Importer:
self.tuplet_sequence += 1
actual = item.findtext('tuplet-actual/tuplet-number', modulation.findtext('actual-notes') if modulation is not None else '')
normal = item.findtext('tuplet-normal/tuplet-number', modulation.findtext('normal-notes') if modulation is not None else '')
if self.active_tuplets and (item.find('tuplet-actual') is None or item.find('tuplet-normal') is None):
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}', 'start': note['id'], 'note_count': self.number(actual, integer=True, positive=True), 'space_count': self.number(normal, integer=True, positive=True), 'depth': len(self.active_tuplets), 'bracket': item.get('bracket', 'auto'), 'show_number': item.get('show-number', 'actual')}
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:
self.attrs(detail)
self.children(detail, {'tuplet-number', 'tuplet-type', 'tuplet-dot'})
span[tag.replace('-', '_')] = {'number': self.number(detail.findtext('tuplet-number'), integer=True, positive=True), 'type': detail.findtext('tuplet-type'), 'dots': len(detail.findall('tuplet-dot'))}
self.active_tuplets[number] = span
self.tuplets.append(span)
elif action == 'stop':
@@ -421,19 +473,19 @@ class Importer:
action = tremolo.get('type', 'single')
note['tremolo'] = {'marks': marks, 'type': action}
if action == 'start':
if self.tremolo_start is not None:
if voice in self.tremolo_starts:
self.fail('nested tremolo')
self.tremolo_start = note
self.tremolo_starts[voice] = note
elif action == 'stop':
if self.tremolo_start is None:
if voice not in self.tremolo_starts:
self.fail('tremolo stop without start')
note['tremolo']['partner'] = self.tremolo_start['id']
self.tremolo_start['tremolo']['partner'] = note['id']
self.tremolo_start = None
partner = self.tremolo_starts.pop(voice)
note['tremolo']['partner'] = partner['id']
partner['tremolo']['partner'] = note['id']
elif action != 'single':
self.fail('unsupported tremolo type')
if not is_chord:
note['tuplet_ids'] = [t['id'] for t in self.active_tuplets.values()]
note['tuplet_ids'] = [t['id'] for (v, _), t in self.active_tuplets.items() if v == voice]
for number in stops:
if number not in self.active_tuplets:
self.fail('tuplet stop without start')
@@ -448,6 +500,26 @@ class Importer:
definitions = root.findall('part-list/score-part')
if len(parts) != 1 or len(definitions) != 1 or parts[0].get('id') != definitions[0].get('id'):
self.fail('exactly one matching score part is required')
instruments = []
for item in definitions[0].findall('score-instrument'):
self.attrs(item, {'id'})
self.children(item, {'instrument-name', 'instrument-abbreviation', 'instrument-sound', 'solo', 'ensemble', 'virtual-instrument'})
instrument = {'id': item.get('id'), 'name': item.findtext('instrument-name', ''), 'abbreviation': item.findtext('instrument-abbreviation', ''), 'sound': item.findtext('instrument-sound', '')}
if item.find('solo') is not None:
instrument['solo'] = True
if item.find('ensemble') is not None:
instrument['ensemble'] = item.findtext('ensemble')
if item.find('virtual-instrument') is not None:
virtual = item.find('virtual-instrument')
self.children(virtual, {'virtual-library', 'virtual-name'})
instrument['virtual'] = {child.tag: child.text or '' for child in virtual}
midi = next((m for m in definitions[0].findall('midi-instrument') if m.get('id') == instrument['id']), None)
if midi is not None:
self.children(midi, {'midi-channel', 'midi-name', 'midi-bank', 'midi-program', 'midi-unpitched', 'volume', 'pan', 'elevation'})
instrument['midi'] = {child.tag: child.text for child in midi}
instruments.append(instrument)
if any(item.get('id') not in {i['id'] for i in instruments} for item in definitions[0].findall('midi-instrument')):
self.fail('MIDI definition refers to an unknown score instrument')
fields = {}
for item in root.findall('identification/miscellaneous/miscellaneous-field'):
name = item.get('name', '')
@@ -465,23 +537,24 @@ class Importer:
self.location = f'{self.path}: part {parts[0].get("id")}, measure {bar.get("number", str(index + 1))}'
self.children(bar, {'attributes', 'note', 'direction', 'sound', 'harmony', 'forward', 'backup', 'barline', 'print'})
self.attrs(bar, {'number', 'implicit', 'non-controlling', 'width', 'text'})
cursor = Fraction()
events = []
cursor, extent = Fraction(), Fraction()
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)}
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)
if cursor == 0:
for k in ('key', 'meter', 'clef', 'transpose'):
for k in ('key', 'meter', 'clef', 'transpose', 'staff_details'):
measure[k] = copy.deepcopy(getattr(self, k))
elif len(state) > 1:
measure['attributes'].append(state)
elif item.tag == 'note':
previous, cursor, is_chord = self.read_note(item, cursor, previous)
if not is_chord:
events.append(previous)
voices.setdefault(self.voice_id, []).append(previous)
extent = max(extent, cursor)
elif item.tag == 'harmony':
measure['harmonies'].append(self.read_harmony(item, cursor))
elif item.tag == 'direction':
@@ -489,15 +562,21 @@ class Importer:
elif item.tag == 'sound':
self.sound(item, cursor, measure)
elif item.tag == 'backup':
self.fail('backward timeline movement requires unsupported independent voices')
self.children(item, {'duration'})
cursor -= self.number(item.findtext('duration'), positive=True) / self.divisions
if cursor < 0:
self.fail('backup moves before the start of the measure')
previous = None
elif item.tag == 'forward':
self.children(item, {'duration', 'voice', 'staff'})
self.identity(item)
duration = self.number(item.findtext('duration'), positive=True) / self.divisions
self.sequence += 1
from generator.model import make_event
events.append(make_event(f'spacer-{self.sequence}', cursor, duration, 'spacer'))
if item.find('voice') is not None:
voices.setdefault(self.voice_id, []).append(make_event(f'spacer-{self.sequence}', cursor, duration, 'spacer') | {'voice': self.voice_id})
cursor += duration
extent = max(extent, cursor)
previous = None
elif item.tag == 'barline':
self.children(item, {'bar-style'})
@@ -505,15 +584,16 @@ class Importer:
if style not in ('regular', 'light-light', 'light-heavy', 'heavy-light', 'heavy-heavy', 'heavy', 'dashed', 'dotted', 'none', 'tick', 'short'):
self.fail('unsupported barline')
measure.setdefault('barlines', {})[item.get('location', 'right')] = style
if not events or cursor <= 0:
if not voices or extent <= 0:
self.fail('empty or zero-duration measure')
cursor = extent
expected = meter_length(measure['meter'])
meter_changed = any('meter' in a for a in measure['attributes'])
if expected and cursor != expected and not measure['implicit'] and not meter_changed:
self.fail(f'measure has {cursor} quarter notes, expected {expected}; mark a partial measure implicit')
measure['duration'] = rational(cursor)
measure['key_signature'], measure['time_signature'] = key_label(measure['key']), meter_label(measure['meter'])
measure['voices'] = [{'id': 'voice-source', 'notes': events}]
measure['voices'] = [{'id': voice, 'notes': sorted(events, key=lambda n: fraction(n['onset']))} for voice, events in voices.items()]
measure['harmonies'].sort(key=lambda h: fraction(h['onset']))
used = set()
for onset in optional:
@@ -524,16 +604,17 @@ class Importer:
used.add(onset)
measures.append(measure)
self.absolute += cursor
if self.open_ties or self.active_tuplets or self.open_octaves or self.tremolo_start:
if self.open_ties or self.active_tuplets or self.open_octaves or self.tremolo_starts:
self.fail('unterminated tie, tuplet, octave shift, or tremolo')
if not measures:
self.fail('score has no measures')
for measure in measures:
for n in measure['voices'][0]['notes']:
for n in (n for v in measure['voices'] for n in v['notes']):
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 = {'schema_version': SCHEMA_VERSION, '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
validate_document(result)
return result
+316
View File
@@ -0,0 +1,316 @@
from __future__ import annotations
from fractions import Fraction
from math import lcm
from pathlib import Path
from xml.etree import ElementTree
from generator import harmony
from generator.model import alteration, fraction, validate_document
def element(parent, tag, content=None, **attributes):
node = ElementTree.SubElement(parent, tag, {k.replace('_', '-'): str(v) for k, v in attributes.items() if v is not None})
if content is not None:
node.text = str(content)
return node
def decimal(value) -> str:
value = alteration(value) if isinstance(value, dict) else Fraction(value)
denominator = value.denominator
for prime in (2, 5):
while denominator % prime == 0:
denominator //= prime
if denominator != 1:
raise ValueError(f'MusicXML requires a terminating decimal here, not {value}')
digits, remainder = [], abs(value.numerator) % value.denominator
while remainder:
digit, remainder = divmod(remainder * 10, value.denominator)
digits.append(str(digit))
return ('-' if value < 0 else '') + str(abs(value.numerator) // value.denominator) + ('.' + ''.join(digits) if digits else '')
def write_attributes(parent, state: dict, divisions=None) -> None:
node = element(parent, 'attributes')
if divisions is not None:
element(node, 'divisions', divisions)
if key := state.get('key'):
item = element(node, 'key')
if 'cancel' in key:
element(item, 'cancel', key['cancel'])
if key.get('custom'):
steps = key.get('steps', [{'letter': letter, 'alter': amount} for letter, amount in zip('cdefgab', key['alterations']) if alteration(amount)])
for step in steps:
element(item, 'key-step', step['letter'].upper())
element(item, 'key-alter', decimal(step['alter']))
if 'accidental' in step:
element(item, 'key-accidental', step['accidental'])
for index, step in enumerate(steps):
if 'octave' in step:
element(item, 'key-octave', step['octave'], number=index + 1)
else:
element(item, 'fifths', key['fifths'])
element(item, 'mode', key['mode'])
if meter := state.get('meter'):
item = element(node, 'time', symbol=meter.get('symbol', 'normal'))
if meter.get('unmetered'):
element(item, 'senza-misura')
else:
for group in meter['groups']:
element(item, 'beats', '+'.join(map(str, group['beats'])))
element(item, 'beat-type', group['unit'])
if clef := state.get('clef'):
item = element(node, 'clef')
element(item, 'sign', clef['sign'])
element(item, 'line', clef['line'])
if clef['octave_change']:
element(item, 'clef-octave-change', clef['octave_change'])
if details := state.get('staff_details'):
item = element(node, 'staff-details')
element(item, 'staff-lines', details['lines'])
for tuning in details.get('tunings', []):
t = element(item, 'staff-tuning', line=tuning['line'])
element(t, 'tuning-step', tuning['letter'].upper())
element(t, 'tuning-alter', decimal(tuning['alter']))
element(t, 'tuning-octave', tuning['octave'])
if details.get('capo'):
element(item, 'capo', details['capo'])
if transpose := state.get('transpose'):
item = element(node, 'transpose')
for field in ('diatonic', 'chromatic', 'octave_change'):
element(item, field.replace('_', '-'), transpose[field])
def write_harmony(parent, value: dict, divisions: int) -> None:
node = element(parent, 'harmony')
if root := value.get('root'):
item = element(node, 'root')
element(item, 'root-step', root['letter'].upper())
element(item, 'root-alter', decimal(root['alter']))
element(node, 'kind', value['kind'], text=value.get('kind_text'))
if 'inversion' in value:
element(node, 'inversion', value['inversion'])
if 'bass' in value:
item = element(node, 'bass')
element(item, 'bass-step', value['bass']['letter'].upper())
element(item, 'bass-alter', decimal(value['bass']['alter']))
for degree in value['degrees']:
item = element(node, 'degree')
amount = alteration(degree['alter'])
if degree['type'] == 'alter':
amount -= harmony.KINDS[value['kind']].get(degree['value'], 0)
elif degree['type'] == 'add' and (degree['value'] - 1) % 7 == 6:
amount += 1
element(item, 'degree-value', degree['value'])
element(item, 'degree-alter', decimal(amount))
element(item, 'degree-type', degree['type'])
element(node, 'offset', int(fraction(value['onset']) * divisions))
def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[dict]) -> None:
if event['kind'] == 'spacer':
item = element(parent, 'forward')
element(item, 'duration', int(fraction(event['duration']) * divisions))
element(item, 'voice', voice)
return
pitches = event['pitches'] or [None]
for index, pitch in enumerate(pitches):
printing = {'print-object': 'no'} if event.get('hidden') else {}
if pitch:
printing.update(pitch.get('print', {}))
node = element(parent, 'note', **printing)
if grace := event.get('grace'):
element(node, 'grace', **{k: ('yes' if v else 'no') if isinstance(v, bool) else decimal(fraction(v) * divisions if k == 'make-time' else v) for k, v in grace.items()})
if (pitch or event).get('cue', event.get('cue')):
element(node, 'cue')
if index:
element(node, 'chord')
if event['kind'] == 'rest':
rest = element(node, 'rest', measure='yes' if event.get('measure_rest') else None)
if position := event.get('rest_position'):
element(rest, 'display-step', position['letter'].upper())
element(rest, 'display-octave', position['octave'])
elif pitch and 'unpitched' in pitch:
unpitched = element(node, 'unpitched')
element(unpitched, 'display-step', pitch['unpitched']['letter'].upper())
element(unpitched, 'display-octave', pitch['unpitched']['octave'])
else:
item = element(node, 'pitch')
element(item, 'step', pitch['letter'].upper() if pitch else 'B')
element(item, 'alter', decimal(pitch['alter']) if pitch else '0')
element(item, 'octave', pitch['octave'] if pitch else 4)
if not event.get('grace'):
element(node, 'duration', int(fraction(pitch['duration'] if pitch else event['duration']) * divisions))
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
if pitch and pitch.get(field):
element(node, 'tie', type=action)
if pitch and pitch.get('instrument'):
element(node, 'instrument', id=pitch['instrument'])
element(node, 'voice', voice)
written = pitch['written'] if pitch else event['written']
element(node, 'type', written['type'])
for _ in range(written.get('dots', 0)):
element(node, 'dot')
if pitch and (display := pitch.get('accidental_display')):
element(node, 'accidental', display['name'], **{k: v for k, v in display.items() if k != 'name'})
if 'ratio' in written:
ratio = fraction(written['ratio'])
item = element(node, 'time-modification')
element(item, 'actual-notes', ratio.denominator)
element(item, 'normal-notes', ratio.numerator)
if normal := written.get('normal'):
element(item, 'normal-type', normal['type'])
for _ in range(normal.get('dots', 0)):
element(item, 'normal-dot')
if stem := (pitch or event).get('stem', event.get('stem')):
element(node, 'stem', stem)
head = pitch.get('notehead') if pitch else None
if head:
element(node, 'notehead', head['name'], **{k: v for k, v in head.items() if k != 'name'})
elif event.get('notehead'):
element(node, 'notehead', event['notehead'])
if index == 0:
for beam in event.get('beams', []):
element(node, 'beam', beam['type'], number=beam['level'])
notations = element(node, 'notations')
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
if pitch and pitch.get(field):
element(notations, 'tied', type=action)
if index == 0:
for span in tuplets:
for endpoint, action in (('start', 'start'), ('end', 'stop')):
if span[endpoint] != event['id']:
continue
item = element(notations, 'tuplet', type=action, number=span['depth'] + 1, bracket=None if span['bracket'] == 'auto' else span['bracket'], show_number=span['show_number'], show_type=span.get('show_type', 'none'), line_shape=span.get('line_shape', 'straight'))
if action == 'start':
for tag, field in (('tuplet-actual', 'note_count'), ('tuplet-normal', 'space_count')):
detail = span.get(tag.replace('-', '_'), {})
group = element(item, tag)
element(group, 'tuplet-number', span[field])
if detail.get('type'):
element(group, 'tuplet-type', detail['type'])
for _ in range(detail.get('dots', 0)):
element(group, 'tuplet-dot')
if tremolo := event.get('tremolo'):
element(element(notations, 'ornaments'), 'tremolo', tremolo['marks'], type=tremolo['type'])
if pitch and (tab := pitch.get('tablature')):
technical = element(notations, 'technical')
element(technical, 'string', tab['string'])
element(technical, 'fret', tab['fret'])
if not len(notations):
node.remove(notations)
if index == 0:
for verse in event.get('lyrics', []):
lyric = element(node, 'lyric', number=verse['number'])
for syllable in verse['syllables']:
element(lyric, syllable['kind'], syllable['text'], **syllable['attributes'])
def musicxml_tree(document: dict, *, engine_voices: bool = False) -> ElementTree.ElementTree:
validate_document(document)
root = ElementTree.Element('score-partwise', {'version': '4.0'})
metadata, staff = document['metadata'], document['staves'][0]
voice_ids = list(dict.fromkeys(v['id'] for m in staff['measures'] for v in m['voices'] if v['id'] != 'voice-arranged'))
voice_slots = {identifier: str(index + 1) for index, identifier in enumerate(voice_ids)}
voice_slots['voice-arranged'] = '1'
element(element(root, 'work'), 'work-title', metadata['title'])
identification = element(root, 'identification')
element(identification, 'creator', metadata.get('composer', ''), type='composer')
miscellaneous = element(identification, 'miscellaneous')
element(miscellaneous, 'miscellaneous-field', metadata.get('style', 'Medium Swing'), name='vamp:style')
if metadata.get('transpose_origin'):
from generator.model import pitch_name
element(miscellaneous, 'miscellaneous-field', pitch_name(metadata['transpose_origin']), name='vamp:tonic')
defaults = element(root, 'defaults')
element(defaults, 'music-font', font_family='Petaluma')
element(defaults, 'word-font', font_family='Petaluma Script')
element(defaults, 'lyric-font', font_family='Petaluma Script')
definition = element(element(root, 'part-list'), 'score-part', id='P1')
element(definition, 'part-name', staff['name'])
for instrument in staff.get('instruments', []):
item = element(definition, 'score-instrument', id=instrument['id'])
element(item, 'instrument-name', instrument['name'])
if instrument['abbreviation']:
element(item, 'instrument-abbreviation', instrument['abbreviation'])
if instrument['sound']:
element(item, 'instrument-sound', instrument['sound'])
if instrument.get('solo'):
element(item, 'solo')
if 'ensemble' in instrument:
element(item, 'ensemble', instrument['ensemble'])
if 'virtual' in instrument:
virtual = element(item, 'virtual-instrument')
for tag, value in instrument['virtual'].items():
element(virtual, tag, value)
for instrument in staff.get('instruments', []):
if instrument.get('midi'):
item = element(definition, 'midi-instrument', id=instrument['id'])
for tag, value in instrument['midi'].items():
element(item, tag, value)
part = element(root, 'part', id='P1')
absolute = Fraction()
previous_state = {}
for index, measure in enumerate(staff['measures']):
bar = element(part, 'measure', number=index + 1, implicit='yes' if measure['implicit'] else 'no')
times = [fraction(measure['duration'])]
times.extend(fraction(n[field]) for v in measure['voices'] for n in v['notes'] for field in ('onset', 'duration'))
times.extend(fraction(p['duration']) for v in measure['voices'] for n in v['notes'] for p in n['pitches'])
times.extend(fraction(item['onset']) for field in ('attributes', 'harmonies', 'tempos') for item in measure.get(field, []))
times.extend(fraction(s[field]) - absolute for s in staff.get('octave_shifts', []) for field in ('start', 'end'))
divisions = lcm(*(t.denominator for t in times))
state = {k: measure[k] for k in ('key', 'meter', 'clef', 'transpose', 'staff_details') if k in measure}
write_attributes(bar, {k: v for k, v in state.items() if previous_state.get(k) != v}, divisions)
previous_state.update(state)
cursor = Fraction()
for change in measure.get('attributes', []):
onset = fraction(change['onset'])
if onset != cursor:
element(element(bar, 'forward' if onset > cursor else 'backup'), 'duration', int(abs(onset - cursor) * divisions))
write_attributes(bar, change)
previous_state.update({k: v for k, v in change.items() if k != 'onset'})
cursor = onset
if cursor:
element(element(bar, 'backup'), 'duration', int(cursor * divisions))
for value in measure['harmonies']:
write_harmony(bar, value, divisions)
if value.get('chord_optional'):
direction = element(bar, 'direction', print_object='no')
element(element(direction, 'direction-type'), 'words', 'vamp:optional-chord')
element(direction, 'offset', int(fraction(value['onset']) * divisions))
if measure.get('section_label'):
element(element(element(bar, 'direction'), 'direction-type'), 'rehearsal', measure['section_label'], halign='left')
for tempo in measure.get('tempos', []):
direction = element(bar, 'direction')
metronome = element(element(direction, 'direction-type'), 'metronome')
element(metronome, 'beat-unit', tempo['written']['type'])
for _ in range(tempo['written'].get('dots', 0)):
element(metronome, 'beat-unit-dot')
element(metronome, 'per-minute', decimal(tempo['bpm']))
element(direction, 'offset', int(fraction(tempo['onset']) * divisions))
for si, span in enumerate(staff.get('octave_shifts', [])):
for field in ('start', 'end'):
onset = fraction(span[field]) - absolute
inside = 0 <= onset < fraction(measure['duration']) if field == 'start' else 0 < onset <= fraction(measure['duration'])
if not inside:
continue
direction = element(bar, 'direction')
element(element(direction, 'direction-type'), 'octave-shift', type='stop' if field == 'end' else 'up' if span['octaves'] > 0 else 'down', size=abs(span['octaves']) * 7 + 1, number=si + 1)
element(direction, 'offset', int(onset * divisions))
cursor = Fraction()
for voice in measure['voices']:
if cursor:
element(element(bar, 'backup'), 'duration', int(cursor * divisions))
cursor = Fraction()
for event in voice['notes']:
onset = fraction(event['onset'])
if onset > cursor:
element(element(bar, 'forward'), 'duration', int((onset - cursor) * divisions))
write_note(bar, event, voice_slots[voice['id']] if engine_voices else voice['id'], divisions, staff.get('tuplets', []))
cursor = onset + fraction(event['duration'])
if cursor < fraction(measure['duration']):
element(element(bar, 'forward'), 'duration', int((fraction(measure['duration']) - cursor) * divisions))
cursor = fraction(measure['duration'])
for location, style in measure.get('barlines', {}).items():
element(element(bar, 'barline', location=location), 'bar-style', style)
absolute += fraction(measure['duration'])
return ElementTree.ElementTree(root)
def write_musicxml(path: Path, document: dict, *, engine_voices: bool = False) -> None:
tree = musicxml_tree(document, engine_voices=engine_voices)
ElementTree.indent(tree, space=' ')
tree.write(path, encoding='utf-8', xml_declaration=True)
+4 -4
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from pathlib import Path
from generator.score import copy_source_voice, make_arranged_document, make_measure, reidentify, slugify, source_staff, write_json
from generator.score import copy_source_voices, make_arranged_document, make_measure, reidentify, slugify, source_staff, write_json
from generator.musicxml import load_musicxml
from generator.transposition import TARGET_KEYS, transpose_document
@@ -13,14 +13,14 @@ def practice_document(document: dict) -> dict:
raise ValueError("practice source must contain measures")
measures: list[dict] = []
for index, measure in enumerate(source_measure for source_measure in source_measures if isinstance(source_measure, dict)):
voice = copy_source_voice(measure, strip_lyrics=True)
voices = copy_source_voices(measure, strip_lyrics=True)
output_measure = make_measure(
f"measure-{index + 1}",
measure,
voice["notes"],
[],
section_label=str(measure.get("section_label", "")),
)
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
output_measure['voices'] = voices
measures.append(output_measure)
tuplets = reidentify(measures, staff.get('tuplets', []))
return make_arranged_document(
+33 -11
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import copy
import hashlib
import json
import re
from pathlib import Path
@@ -9,6 +10,27 @@ from generator.model import SCHEMA_VERSION, validate_document
def load_json(path: Path) -> dict:
document = json.loads(path.read_text(encoding='utf-8'))
if 'music' in document:
from generator.engraving import ENGINE_VERSION, PAGE_HEIGHT, PAGE_WIDTH, check_page
presentation = document.get('presentation', {})
if document.get('schema_version') != SCHEMA_VERSION 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('engine_version'), presentation.get('width'), presentation.get('height')) != ('MuseScore Studio', ENGINE_VERSION, PAGE_WIDTH, PAGE_HEIGHT):
raise ValueError('unsupported score presentation; regenerate score assets')
for page in presentation['pages']:
if not isinstance(page, dict) or not isinstance(page.get('path'), str) or not isinstance(page.get('sha256'), str):
raise ValueError('invalid score page manifest')
name = page['path']
relative = 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')
if not re.fullmatch('[0-9a-f]{64}', page['sha256']) or hashlib.sha256(resolved.read_bytes()).hexdigest() != page['sha256']:
raise ValueError('score page checksum mismatch')
check_page(resolved)
document = {'schema_version': SCHEMA_VERSION, **document['music']}
validate_document(document)
return document
@@ -28,13 +50,8 @@ def source_staff(document: dict) -> dict:
raise ValueError('exactly one source staff is required')
return document['staves'][0]
def first_voice(measure: dict) -> dict:
if len(measure.get('voices', [])) != 1:
raise ValueError('exactly one source voice is required')
return measure['voices'][0]
def measure_notes(measure: dict) -> list[dict]:
return first_voice(measure)['notes']
return [note for voice in measure['voices'] for note in voice['notes']]
def leading_pickup_measure_index(measures: list[dict]) -> int | None:
return 0 if len(measures) > 1 and measures[0].get('implicit') else None
@@ -42,17 +59,20 @@ def leading_pickup_measure_index(measures: list[dict]) -> int | None:
def body_start_measure_index(measures: list[dict]) -> int:
return 1 if leading_pickup_measure_index(measures) is not None else 0
def copy_source_voice(measure: dict, *, strip_lyrics: bool) -> dict:
voice = copy.deepcopy(first_voice(measure))
def copy_source_voices(measure: dict, *, strip_lyrics: bool) -> list[dict]:
voices = copy.deepcopy(measure['voices'])
if strip_lyrics:
for note in voice['notes']:
for note in (n for v in voices for n in v['notes']):
note.pop('lyric', None)
note.pop('lyric_notation', None)
return voice
note.pop('lyrics', None)
return voices
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}]})
for note in notes:
note['voice'] = 'voice-arranged'
return result
def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
@@ -60,6 +80,8 @@ def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
tone_map, event_map = {}, {}
tuplets = []
for measure in measures:
for hi, harmony in enumerate(measure.get('harmonies', [])):
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}'
@@ -95,7 +117,7 @@ def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
return tuplets
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 {'schema_version': SCHEMA_VERSION, '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': octave_shifts or []}]}
return {'schema_version': SCHEMA_VERSION, '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': octave_shifts or [], 'instruments': copy.deepcopy(staff.get('instruments', []))}]}
def count_json_files(root: Path) -> int:
return sum(1 for p in root.rglob('*.json') if p.is_file())
+16 -6
View File
@@ -4,7 +4,7 @@ import copy
from dataclasses import dataclass
from generator import harmony
from generator.model import LETTERS, NATURAL, key_from_fifths, key_label, parse_pitch_name, pitch_number
from generator.model import LETTERS, NATURAL, alteration, key_from_fifths, key_label, parse_pitch_name, pitch_number, rational
@dataclass(frozen=True)
class KeyProfile:
@@ -23,7 +23,7 @@ def find_key(name: str) -> KeyProfile:
for fifths in range(-14, 15):
key = key_from_fifths(fifths)
if key['tonic'] == tonic:
return KeyProfile(name, (NATURAL[tonic['letter']] + tonic['alter']) % 12, fifths)
return KeyProfile(name, int(NATURAL[tonic['letter']] + alteration(tonic['alter'])) % 12, fifths)
raise ValueError(f'unsupported key signature: {name}')
def signed_interval(source: KeyProfile, target: KeyProfile) -> int:
@@ -31,7 +31,7 @@ def signed_interval(source: KeyProfile, target: KeyProfile) -> int:
return value - 12 if value > 6 else value
def interval_between(source: dict, target: dict) -> tuple[int, int]:
semitones = (NATURAL[target['letter']] + target['alter'] - NATURAL[source['letter']] - source['alter']) % 12
semitones = (NATURAL[target['letter']] + alteration(target['alter']) - NATURAL[source['letter']] - alteration(source['alter'])) % 12
if semitones > 6:
semitones -= 12
steps = (LETTERS.index(target['letter']) - LETTERS.index(source['letter'])) % 7
@@ -41,16 +41,24 @@ def interval_between(source: dict, target: dict) -> tuple[int, int]:
def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict:
output = copy.deepcopy(pitch)
if 'unpitched' in pitch:
return output
original_octave = pitch.get('octave', 4)
diatonic = original_octave * 7 + LETTERS.index(pitch['letter']) + steps
octave, index = divmod(diatonic, 7)
output['letter'] = LETTERS[index]
output['octave'] = octave
output['alter'] = pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + NATURAL[output['letter']])
output['alter'] = rational(pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + NATURAL[output['letter']]))
if 'octave' not in pitch:
output.pop('octave')
if 'accidental_display' in output:
output['accidental_display']['name'] = {-3: 'triple-flat', -2: 'flat-flat', -1: 'flat', 0: 'natural', 1: 'sharp', 2: 'double-sharp', 3: 'triple-sharp'}.get(output['alter'], 'compound')
from generator.model import accidental_name
output['accidental_display']['name'] = accidental_name(alteration(output['alter']))
if 'tablature' in output and semitones:
fret = output['tablature']['fret'] + semitones
if fret < 0 or fret.denominator != 1:
raise ValueError('transposition cannot preserve the authored tablature string/fret')
output['tablature']['fret'] = int(fret)
return output
def shift_key(key: dict, steps: int, semitones: int) -> dict:
@@ -60,6 +68,8 @@ def shift_key(key: dict, steps: int, semitones: int) -> dict:
p = shift_pitch({'letter': letter, 'alter': key['alterations'][index]}, steps, semitones)
alterations[LETTERS.index(p['letter'])] = p['alter']
result = {**copy.deepcopy(key), 'tonic': tonic, 'alterations': alterations}
if 'steps' in key:
result['steps'] = [shift_pitch(step, steps, semitones) for step in key['steps']]
if not key.get('custom') and tonic:
for fifths in range(-14, 15):
candidate = key_from_fifths(fifths, key['mode'])
@@ -119,7 +129,7 @@ def concert_document(document: dict) -> dict:
from generator.model import fraction
measure['key'] = shift_key(measure['key'], *interval(0))
measure['key_signature'] = key_label(measure['key'])
for n in measure['voices'][0]['notes']:
for n in (n for v in measure['voices'] for n in v['notes']):
n['pitches'] = [shift_pitch(p, *interval(fraction(n['onset']))) for p in n['pitches']]
for h in measure['harmonies']:
for field in ('root', 'bass'):