@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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'):
|
||||
|
||||
Reference in New Issue
Block a user