notepilot / src /convert_score.py
jasonmdong's picture
Sync from GitHub via hub-sync
a9358a6 verified
Raw
History Blame Contribute Delete
60.2 kB
"""
Convert a MusicXML file into a score.py for accompy.
Usage:
python convert_score.py mysong.mxl
python convert_score.py mysong.xml --out score.py
python convert_score.py mysong.mscx --out score.py
The script expects:
- Part 0 (first staff / treble): right-hand melody
- Part 1 (second staff / bass): left-hand accompaniment
If the piece only has one part, the left hand will be empty.
"""
import sys
import os
import re
import argparse
import shutil
import subprocess
import tempfile
import zipfile
import xml.etree.ElementTree as ET
from music21 import (
articulations as m21articulations,
converter,
note,
chord,
expressions,
pitch,
dynamics as m21dynamics,
stream,
meter,
metadata,
)
from src.fingering import build_fingering_state
from src.paths import get_scores_dir
MUSESCORE_SUFFIXES = {".mscx", ".mscz"}
LILYPOND_SUFFIXES = {".ily", ".ly"}
GRACE_NOTE_BEATS = 0.125
MUSESCORE_DYNAMIC_VELOCITY = {
"n": 49,
"pppppp": 1,
"ppppp": 5,
"pppp": 10,
"ppp": 16,
"pp": 33,
"p": 49,
"mp": 64,
"mf": 80,
"f": 96,
"ff": 112,
"fff": 126,
"ffff": 127,
"fffff": 127,
"ffffff": 127,
"sf": 112,
"sfz": 112,
"sff": 126,
"sffz": 126,
"sfff": 127,
"sfffz": 127,
"rfz": 112,
"rf": 112,
"fz": 112,
"m": 96,
"r": 112,
"s": 112,
"z": 80,
}
DYNAMIC_VELOCITY = {
mark: value / 127.0 for mark, value in MUSESCORE_DYNAMIC_VELOCITY.items()
}
DYNAMIC_ORDINARY = {
"n", "pppppp", "ppppp", "pppp", "ppp", "pp", "p", "mp", "mf", "f", "ff", "fff", "ffff", "fffff", "ffffff"
}
DYNAMIC_SINGLE_NOTE = {"sf", "sfz", "sff", "sffz", "sfff", "sfffz", "rfz", "rf", "fz", "m", "r", "s"}
DYNAMIC_COMPOUND = {
"fp": ("f", "p"),
"pf": ("p", "f"),
"sfp": ("f", "p"),
"sfpp": ("f", "pp"),
}
DYNAMIC_RAMP_STEP = 16 / 127.0
DYNAMIC_DEFAULT_VELOCITY = DYNAMIC_VELOCITY["mf"]
COMPOUND_DYNAMIC_BEATS = 0.8
ARTICULATION_PLAYBACK = {
"accent": {"velocityScale": 1.18},
"strong-accent": {"velocityScale": 1.28, "durationScale": 0.95},
"staccato": {"durationScale": 0.52},
"staccatissimo": {"durationScale": 0.32},
"spiccato": {"durationScale": 0.42, "velocityScale": 0.95},
"tenuto": {"durationScale": 1.08, "velocityScale": 1.04},
"detached-legato": {"durationScale": 0.72},
}
def beat_to_float(beat) -> float:
"""Convert a music21 beat (may be a Fraction) to a plain float."""
# music21 measures start at beat 1; we want 0-indexed quarter-note offsets.
# 'offset' (not 'beat') is what we use — it's already 0-indexed from the
# start of the piece in quarter-note units.
return float(beat)
def _starts_new_attack(el) -> bool:
tie = getattr(el, "tie", None)
if tie is None:
return True
return tie.type not in {"stop", "continue"}
def _note_tie_duration(part, start_note) -> float:
duration = float(start_note.quarterLength)
tie = getattr(start_note, "tie", None)
if tie is None or tie.type != "start":
return duration
found_start = False
start_pitch = start_note.pitch.midi
start_offset = float(start_note.offset)
for later in part.flatten().notesAndRests:
if isinstance(later, note.Note):
members = [later]
elif isinstance(later, chord.Chord):
members = list(later.notes)
else:
continue
later_offset = float(later.offset)
if not found_start:
if later is start_note or (
later_offset == start_offset
and any(member is start_note for member in members)
):
found_start = True
continue
for member in members:
later_tie = getattr(member, "tie", None)
if member.pitch.midi != start_pitch or later_tie is None:
continue
if later_tie.type in {"continue", "stop"}:
duration += float(member.quarterLength)
if later_tie.type == "stop":
return duration
return duration
def _dynamic_marks(container, anchor=None) -> list[tuple[float, str, float]]:
anchor = anchor or container
marks = []
for dynamic in container.recurse().getElementsByClass(m21dynamics.Dynamic):
value = str(getattr(dynamic, "value", "") or "").lower()
if value not in DYNAMIC_VELOCITY and value not in DYNAMIC_COMPOUND:
continue
velocity = (
_dynamic_velocity(DYNAMIC_COMPOUND[value][0])
if value in DYNAMIC_COMPOUND
else DYNAMIC_VELOCITY[value]
)
try:
beat = float(dynamic.getOffsetInHierarchy(anchor))
except Exception:
beat = float(getattr(dynamic, "offset", 0.0) or 0.0)
marks.append((beat, value, velocity))
marks.sort(key=lambda item: item[0])
return marks
def _dynamic_velocity(mark: str) -> float:
return DYNAMIC_VELOCITY.get(mark, DYNAMIC_DEFAULT_VELOCITY)
def _dynamic_curve_progress(progress: float, method: str = "normal") -> float:
progress = max(0.0, min(1.0, progress))
if method == "ease-in":
return progress * progress
if method == "ease-out":
return 1 - (1 - progress) * (1 - progress)
if method == "ease-in-out":
return progress * progress * (3 - 2 * progress)
return progress
def _dynamic_wedges(container, anchor=None) -> list[dict]:
anchor = anchor or container
wedges = []
for wedge in container.recurse().getElementsByClass(m21dynamics.DynamicWedge):
try:
spanned = list(wedge.getSpannedElements())
except Exception:
spanned = []
if len(spanned) >= 2:
try:
start = float(spanned[0].getOffsetInHierarchy(anchor))
end = float(spanned[-1].getOffsetInHierarchy(anchor))
except Exception:
start = float(getattr(spanned[0], "offset", 0.0) or 0.0)
end = float(getattr(spanned[-1], "offset", start) or start)
end += float(getattr(spanned[-1], "quarterLength", 0.0) or 0.0)
else:
start = float(getattr(wedge, "offset", 0.0) or 0.0)
end = start + float(getattr(wedge, "quarterLength", 0.0) or 0.0)
if end <= start + 0.0001:
continue
direction = -1 if isinstance(wedge, m21dynamics.Diminuendo) else 1
wedges.append({"start": start, "end": end, "direction": direction})
wedges.sort(key=lambda item: (item["start"], item["end"]))
return wedges
def _dynamic_mark_after(
dynamic_marks: list[tuple[float, str, float]],
beat: float,
*,
ordinary_only: bool = False,
) -> tuple[str, float] | None:
for mark_beat, value, velocity in dynamic_marks:
if mark_beat >= beat - 0.0001:
if ordinary_only and value not in DYNAMIC_ORDINARY:
continue
return value, velocity
return None
def _dynamic_baseline_at(dynamic_marks: list[tuple[float, str, float]], beat: float) -> tuple[str | None, float | None]:
active: tuple[str | None, float | None] = (None, None)
for mark_beat, value, velocity in dynamic_marks:
if mark_beat > beat + 0.0001:
break
if value in DYNAMIC_ORDINARY:
active = (value, velocity)
elif value in DYNAMIC_COMPOUND:
_start, end = DYNAMIC_COMPOUND[value]
if beat >= mark_beat + COMPOUND_DYNAMIC_BEATS - 0.0001:
active = (end, _dynamic_velocity(end))
elif value in DYNAMIC_SINGLE_NOTE and abs(mark_beat - beat) <= 0.0001:
return value, velocity
return active
def _dynamic_at_beat(
dynamic_marks: list[tuple[float, str, float]],
dynamic_wedges: list[dict],
beat: float,
) -> dict | None:
mark, velocity = _dynamic_baseline_at(dynamic_marks, beat)
for mark_beat, value, _velocity in dynamic_marks:
if value not in DYNAMIC_COMPOUND:
continue
if not (mark_beat - 0.0001 <= beat <= mark_beat + COMPOUND_DYNAMIC_BEATS + 0.0001):
continue
start_mark, end_mark = DYNAMIC_COMPOUND[value]
start_velocity = _dynamic_velocity(start_mark)
end_velocity = _dynamic_velocity(end_mark)
progress = _dynamic_curve_progress((beat - mark_beat) / COMPOUND_DYNAMIC_BEATS)
velocity = start_velocity + (end_velocity - start_velocity) * progress
mark = value
break
for wedge in dynamic_wedges:
start = float(wedge["start"])
end = float(wedge["end"])
if not (start - 0.0001 <= beat <= end + 0.0001):
continue
_start_mark, start_velocity = _dynamic_baseline_at(dynamic_marks, start)
if start_velocity is None:
start_velocity = DYNAMIC_DEFAULT_VELOCITY
next_mark = _dynamic_mark_after(dynamic_marks, end, ordinary_only=True)
if next_mark is not None:
end_velocity = next_mark[1]
else:
end_velocity = start_velocity + DYNAMIC_RAMP_STEP * (1 if wedge["direction"] > 0 else -1)
if wedge["direction"] > 0:
end_velocity = max(end_velocity, start_velocity + 0.04)
else:
end_velocity = min(end_velocity, start_velocity - 0.04)
progress = _dynamic_curve_progress((beat - start) / max(0.0001, end - start))
velocity = start_velocity + (end_velocity - start_velocity) * progress
mark = "crescendo" if wedge["direction"] > 0 else "diminuendo"
break
if velocity is None:
return None
velocity = max(0.08, min(1.0, velocity))
if mark in DYNAMIC_VELOCITY:
return {"type": "dynamic", "mark": mark, "velocity": velocity}
return {"type": "dynamic", "mark": mark or "dynamic", "velocity": velocity}
def _articulation_name(articulation) -> str | None:
if isinstance(articulation, m21articulations.StrongAccent):
return "strong-accent"
if isinstance(articulation, m21articulations.Accent):
return "accent"
if isinstance(articulation, m21articulations.Staccatissimo):
return "staccatissimo"
if isinstance(articulation, m21articulations.Staccato):
return "staccato"
if isinstance(articulation, m21articulations.Spiccato):
return "spiccato"
if isinstance(articulation, m21articulations.Tenuto):
return "tenuto"
if isinstance(articulation, m21articulations.DetachedLegato):
return "detached-legato"
name = articulation.__class__.__name__.lower()
return name if name in ARTICULATION_PLAYBACK else None
def _articulations_for_event(el) -> list[str]:
names = []
for articulation in getattr(el, "articulations", []) or []:
name = _articulation_name(articulation)
if name and name not in names:
names.append(name)
return names
def _combined_articulation_payload(articulations: list[str]) -> dict | None:
if not articulations:
return None
duration_scale = 1.0
velocity_scale = 1.0
for name in articulations:
playback = ARTICULATION_PLAYBACK.get(name) or {}
duration_scale *= float(playback.get("durationScale", 1.0))
velocity_scale *= float(playback.get("velocityScale", 1.0))
return {
"type": "articulation",
"names": articulations,
"durationScale": max(0.2, min(1.25, duration_scale)),
"velocityScale": max(0.6, min(1.6, velocity_scale)),
}
def _tremolo_marks(number_of_marks) -> int:
try:
marks = int(number_of_marks)
except (TypeError, ValueError):
marks = 3
return max(1, min(4, marks))
def _single_tremolo(el):
for expr in getattr(el, "expressions", []):
if isinstance(expr, expressions.Tremolo):
return expr
return None
def _trill_expression(el):
for expr in getattr(el, "expressions", []):
if isinstance(expr, expressions.Trill):
return expr
return None
def _is_grace(el) -> bool:
return bool(getattr(getattr(el, "duration", None), "isGrace", False))
def _pitches_for_attack(el) -> list[int]:
if isinstance(el, note.Note):
return [el.pitch.midi] if _starts_new_attack(el) else []
if isinstance(el, chord.Chord):
return [n.pitch.midi for n in el.notes if _starts_new_attack(n)]
return []
def _note_members(el):
if isinstance(el, note.Note):
return [el]
if isinstance(el, chord.Chord):
return list(el.notes)
return []
def _attack_duration(part, el) -> float:
if isinstance(el, note.Note):
return _note_tie_duration(part, el)
if isinstance(el, chord.Chord):
attacked = [n for n in el.notes if _starts_new_attack(n)]
if not attacked:
return float(el.quarterLength)
return max(_note_tie_duration(part, n) for n in attacked)
return float(getattr(el, "quarterLength", 0.0) or 0.0)
def _add_grouped_event(
grouped: dict,
beat: float,
pitches: list[int],
duration: float,
ornament: dict | None = None,
articulations: list[str] | None = None,
):
if not pitches:
return
slot = grouped.setdefault(beat, {"pitches": [], "duration": 0.0, "ornaments": [], "articulations": []})
slot["pitches"].extend(pitches)
slot["duration"] = max(slot["duration"], duration)
if ornament:
slot["ornaments"].append(ornament)
for articulation in articulations or []:
if articulation not in slot["articulations"]:
slot["articulations"].append(articulation)
def _iter_tremolo_attacks(start: float, end: float, interval: float, pitch_groups: list[list[int]]):
if not pitch_groups or end <= start:
return
interval = max(0.0625, interval)
beat = start
idx = 0
while beat < end - 0.0001:
yield beat, pitch_groups[idx % len(pitch_groups)], min(interval, end - beat)
beat += interval
idx += 1
def _explicit_neighbor_accidental_midi(default_midi: int, flat_events: list, current_idx: int) -> int | None:
target = pitch.Pitch()
target.midi = default_midi
try:
source_measure = flat_events[current_idx].measureNumber
except Exception:
source_measure = None
def matches(candidate) -> bool:
if source_measure is not None and getattr(candidate, "measureNumber", None) != source_measure:
return False
return candidate.pitch.step == target.step and candidate.pitch.octave == target.octave
for candidate_el in flat_events[current_idx + 1:]:
for candidate in _note_members(candidate_el):
if matches(candidate) and candidate.pitch.accidental is not None:
return candidate.pitch.midi
if source_measure is not None and getattr(candidate_el, "measureNumber", None) != source_measure:
break
for candidate_el in reversed(flat_events[:current_idx]):
if source_measure is not None and getattr(candidate_el, "measureNumber", None) != source_measure:
break
for candidate in _note_members(candidate_el):
if matches(candidate) and candidate.pitch.accidental is not None:
return candidate.pitch.midi
return None
def _trill_pitch_groups(el, trill, flat_events: list, current_idx: int) -> list[list[int]]:
pitches = _pitches_for_attack(el)
if not pitches:
return []
main_pitch = pitches[-1]
try:
trill.resolveOrnamentalPitches(el)
ornamental_pitch = trill.ornamentalPitch
neighbor_pitch = ornamental_pitch.midi if ornamental_pitch else main_pitch + 2
except Exception:
neighbor_pitch = main_pitch + 2
neighbor_pitch = _explicit_neighbor_accidental_midi(neighbor_pitch, flat_events, current_idx) or neighbor_pitch
return [[main_pitch], [neighbor_pitch]]
def _pedal_spans(container, anchor=None) -> list[tuple[float, float]]:
spans = []
anchor = anchor or container
for pedal in container.recurse().getElementsByClass(expressions.PedalMark):
try:
first = pedal.getFirst()
last = pedal.getLast()
if not first or not last:
continue
start = float(first.getOffsetInHierarchy(anchor))
end = float(last.getOffsetInHierarchy(anchor))
if end < start:
start, end = end, start
spans.append((start, end))
except Exception:
continue
spans.sort(key=lambda item: (item[0], item[1]))
return spans
def _apply_pedal(events: list, pedal_spans: list[tuple[float, float]]) -> list:
if not events or not pedal_spans:
return events
for event in events:
beat = float(event[1])
end = beat + float(event[2])
pedal_release = None
for pedal_start, pedal_end in pedal_spans:
if pedal_end <= beat:
continue
if pedal_start > end:
break
if pedal_start <= beat <= pedal_end or pedal_start <= end <= pedal_end:
end = max(end, pedal_end)
pedal_release = max(pedal_release or pedal_end, pedal_end)
event[2] = max(0.125, end - beat)
if pedal_release is not None:
if len(event) > 3:
event[3] = pedal_release
else:
event.append(pedal_release)
next_attack_by_pitch = {}
for event in reversed(events):
beat = float(event[1])
pitches = event[0] if isinstance(event[0], list) else [event[0]]
end = beat + float(event[2])
pedaled = len(event) > 3 and event[3] is not None
for pitch in pitches:
next_attack = next_attack_by_pitch.get(pitch)
if not pedaled and next_attack is not None and next_attack > beat:
end = min(end, next_attack)
event[2] = max(0.125, end - beat)
for pitch in pitches:
next_attack_by_pitch[pitch] = beat
return events
def extract_events(part, pedal_spans: list[tuple[float, float]] | None = None) -> list:
"""Return [[midi_pitch|[midi_pitches], offset, duration], ...].
Tied continuations/stops are treated as sustain, not a fresh attack. That
keeps held notes from being emitted twice when the notation splits them
across beats or measures. Pedal spans extend the event duration so sustained
piano writing does not collapse into short detached notes on playback.
"""
grouped = {}
dynamic_marks = _dynamic_marks(part)
dynamic_wedges = _dynamic_wedges(part)
flat_events = list(part.flatten().notesAndRests)
grace_counts_by_beat = {}
grace_index_by_id = {}
for event_idx, el in enumerate(flat_events):
if not isinstance(el, (note.Note, chord.Chord)) or not _is_grace(el):
continue
beat = beat_to_float(el.offset)
idx = grace_counts_by_beat.get(beat, 0)
grace_index_by_id[id(el)] = idx
grace_counts_by_beat[beat] = idx + 1
def playback_start(el) -> float:
beat = beat_to_float(el.offset)
if _is_grace(el):
return beat + grace_index_by_id.get(id(el), 0) * GRACE_NOTE_BEATS
return beat + grace_counts_by_beat.get(beat, 0) * GRACE_NOTE_BEATS
tremolo_spanner_by_first_id = {}
tremolo_spanner_element_ids = set()
for tremolo_spanner in part.recurse().getElementsByClass(expressions.TremoloSpanner):
spanned = [el for el in tremolo_spanner.getSpannedElements() if isinstance(el, (note.Note, chord.Chord))]
if len(spanned) < 2:
continue
tremolo_spanner_by_first_id[id(spanned[0])] = (tremolo_spanner, spanned)
tremolo_spanner_element_ids.update(id(el) for el in spanned)
for event_idx, el in enumerate(flat_events):
if _is_grace(el):
_add_grouped_event(
grouped,
playback_start(el),
_pitches_for_attack(el),
GRACE_NOTE_BEATS,
articulations=_articulations_for_event(el),
)
continue
spanner_info = tremolo_spanner_by_first_id.get(id(el))
if spanner_info:
tremolo_spanner, spanned = spanner_info
pitch_groups = [_pitches_for_attack(spanned_el) for spanned_el in spanned]
pitch_groups = [pitches for pitches in pitch_groups if pitches]
start = playback_start(spanned[0])
end = max(
beat_to_float(spanned_el.offset) + float(spanned_el.quarterLength)
for spanned_el in spanned
)
_add_grouped_event(
grouped,
start,
pitch_groups[0],
max(0.125, end - start),
{
"type": "tremolo",
"marks": _tremolo_marks(getattr(tremolo_spanner, "numberOfMarks", 3)),
"groups": pitch_groups,
},
_articulations_for_event(spanned[0]),
)
continue
if id(el) in tremolo_spanner_element_ids:
continue
trill = _trill_expression(el)
if trill:
start = playback_start(el)
end = beat_to_float(el.offset) + _attack_duration(part, el)
interval = max(0.0625, float(getattr(trill, "quarterLength", 0.125) or 0.125))
pitch_groups = _trill_pitch_groups(el, trill, flat_events, event_idx)
for beat, attack_pitches, duration in _iter_tremolo_attacks(start, end, interval, pitch_groups):
_add_grouped_event(grouped, beat, attack_pitches, duration, articulations=_articulations_for_event(el))
continue
tremolo = _single_tremolo(el)
if tremolo:
pitches = _pitches_for_attack(el)
start = playback_start(el)
end = beat_to_float(el.offset) + _attack_duration(part, el)
_add_grouped_event(
grouped,
start,
pitches,
max(0.125, end - start),
{
"type": "tremolo",
"marks": _tremolo_marks(getattr(tremolo, "numberOfMarks", 3)),
"groups": [pitches],
},
_articulations_for_event(el),
)
continue
if isinstance(el, note.Note):
if _starts_new_attack(el):
beat = playback_start(el)
_add_grouped_event(
grouped,
beat,
[el.pitch.midi],
_note_tie_duration(part, el),
articulations=_articulations_for_event(el),
)
elif isinstance(el, chord.Chord):
pitches = _pitches_for_attack(el)
if pitches:
beat = playback_start(el)
_add_grouped_event(
grouped,
beat,
pitches,
_attack_duration(part, el),
articulations=_articulations_for_event(el),
)
events = []
for beat in sorted(grouped.keys()):
pitches = sorted(set(grouped[beat]["pitches"]))
duration = grouped[beat]["duration"]
if not pitches:
continue
payload = pitches[0] if len(pitches) == 1 else pitches
event = [payload, beat, duration]
ornaments = grouped[beat].get("ornaments") or []
if ornaments:
event.extend([None, ornaments[0] if len(ornaments) == 1 else ornaments])
articulation = _combined_articulation_payload(grouped[beat].get("articulations") or [])
if articulation:
if len(event) == 3:
event.append(None)
event.append(articulation)
dynamic = _dynamic_at_beat(dynamic_marks, dynamic_wedges, beat)
if dynamic:
if len(event) == 3:
event.append(None)
event.append(dynamic)
events.append(event)
return _apply_pedal(events, pedal_spans if pedal_spans is not None else _pedal_spans(part))
def slugify_score_name(raw: str) -> str:
name = os.path.splitext(os.path.basename(raw))[0]
name = re.sub(r'[^a-zA-Z0-9_]+', '_', name).strip('_').lower()
return name or "imported_score"
def humanize_score_title(raw: str) -> str:
base = os.path.splitext(os.path.basename(raw))[0]
cleaned = re.sub(r'[_\-]+', ' ', base).strip()
return cleaned.title() if cleaned else "Untitled"
def write_score_py(
parts_data: list,
out_path: str,
title: str,
source_ref: str | None = None,
measure_beats: list[float] | None = None,
):
"""
parts_data: [{"name": str, "notes": [[pitch_or_pitches, beat, duration, pedal_release?], ...]}, ...]
Writes PARTS, and also RIGHT_HAND/LEFT_HAND defaulting to part 0 / rest.
"""
# Default RIGHT_HAND = part 0 melody, LEFT_HAND = all other parts merged
right = parts_data[0]['notes'] if parts_data else []
left = []
for p in parts_data[1:]:
for n in p['notes']:
merged = [n[0] if isinstance(n[0], list) else [n[0]], n[1], n[2]]
if len(n) > 3:
merged.append(n[3])
if len(n) > 4:
if len(merged) == 3:
merged.append(None)
merged.extend(n[4:])
left.append(merged)
left.sort(key=lambda x: x[1])
lines = [
f'# Auto-generated: {source_ref or title}',
f'# Title: {title}',
f'# Beat positions are in quarter-note units from the start.',
f'',
f'# All parts — each note is [midi_pitch_or_chord, beat, duration, pedal_release?, ornament?]',
f'PARTS = {parts_data!r}',
f'MEASURE_BEATS = {(measure_beats or [])!r}',
f'',
f'# Defaults: part 0 = melody, remaining parts = accompaniment',
f'RIGHT_HAND = PARTS[0]["notes"] if PARTS else []',
f'LEFT_HAND = []',
f'for _p in PARTS[1:]:',
f' for n in _p["notes"]:',
f' _merged = [n[0] if isinstance(n[0], list) else [n[0]], n[1], n[2]]',
f' if len(n) > 3:',
f' _merged.append(n[3])',
f' if len(n) > 4:',
f' if len(_merged) == 3:',
f' _merged.append(None)',
f' _merged.extend(n[4:])',
f' LEFT_HAND.append(_merged)',
f'LEFT_HAND.sort(key=lambda x: x[1])',
]
with open(out_path, 'w') as f:
f.write('\n'.join(lines) + '\n')
print(f"Written to {out_path}")
for p in parts_data:
print(f" {p['name']}: {len(p['notes'])} notes")
def build_parts_data(score) -> list:
score_parts = score.parts
if len(score_parts) == 0:
raise ValueError("No parts found — is this a valid MusicXML file?")
global_pedals = _pedal_spans(score, anchor=score)
shared_pedal_score = (
len(score_parts) <= 2
and any(_detect_instrument(part) == "piano" for part in score_parts)
)
parts_data = []
for p in score_parts:
part_name = p.partName or f"Part {len(parts_data) + 1}"
instrument = _detect_instrument(p)
source_part_id, source_staff = _source_part_staff(p)
local_pedals = _pedal_spans(p, anchor=score)
if shared_pedal_score:
pedal_spans = sorted(set(local_pedals + global_pedals))
else:
pedal_spans = local_pedals
notes = extract_events(p, pedal_spans=pedal_spans)
part_data = {
"name": part_name,
"instrument": instrument,
"notes": notes,
}
if source_part_id:
part_data["source_part_id"] = source_part_id
if source_staff:
part_data["source_staff"] = source_staff
parts_data.append(part_data)
return parts_data
def score_for_playback(score):
"""Return a sounding-pitch copy for playback extraction.
MusicXML from notation apps often stores transposing instruments at written
pitch, with playback semantics carried by <transpose>. MuseScore playback
uses the sounding pitch; NotePilot's note events should do the same while
sheet rendering keeps the original notation.
"""
try:
return score.toSoundingPitch(inPlace=False)
except Exception:
return score
def _source_part_staff(part) -> tuple[str | None, int | None]:
"""Return MusicXML source part/staff metadata for music21 PartStaff streams."""
part_id = str(getattr(part, "id", "") or "")
match = re.fullmatch(r"(.+)-Staff(\d+)", part_id)
if match:
return match.group(1), int(match.group(2))
return part_id or None, None
def extract_measure_beats(score) -> list[float]:
first_part = score.parts[0] if score.parts else score
return [float(m.offset) for m in first_part.getElementsByClass('Measure')]
def is_musescore_file(source: str) -> bool:
return os.path.splitext(source)[1].lower() in MUSESCORE_SUFFIXES
def _find_musescore_binary() -> str | None:
configured = os.getenv("MUSESCORE_BIN") or os.getenv("MSCORE_BIN")
candidates = [
configured,
"mscore",
"musescore",
"MuseScore",
"mscore4",
"musescore4",
"/Applications/MuseScore 4.app/Contents/MacOS/mscore",
"/Applications/MuseScore 3.app/Contents/MacOS/mscore",
"/Applications/MuseScore.app/Contents/MacOS/mscore",
]
for candidate in candidates:
if not candidate:
continue
if os.path.sep in candidate:
if os.path.exists(candidate):
return candidate
continue
resolved = shutil.which(candidate)
if resolved:
return resolved
return None
def convert_musescore_to_musicxml(source: str, out_dir: str, score_name: str) -> str:
musescore_bin = _find_musescore_binary()
if not musescore_bin:
raise RuntimeError(
"MuseScore is required to import .mscx/.mscz files. Install MuseScore or set "
"MUSESCORE_BIN to the MuseScore executable."
)
out_path = os.path.join(out_dir, f"{score_name}__musescore.musicxml")
command = [musescore_bin, "-f", "-o", out_path, source]
try:
result = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
timeout=120,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError("MuseScore timed out while converting the .mscx file.") from exc
if result.returncode != 0 or not os.path.exists(out_path):
details = "\n".join(
part.strip()
for part in [result.stderr, result.stdout]
if part and part.strip()
)
message = "MuseScore could not convert the .mscx file to MusicXML."
if details:
message = f"{message} {details}"
raise RuntimeError(message)
return out_path
def is_lilypond_file(source: str) -> bool:
return os.path.splitext(source)[1].lower() in LILYPOND_SUFFIXES
def _strip_lilypond_comments(text: str) -> str:
text = re.sub(r"%\{.*?%\}", " ", text, flags=re.S)
return "\n".join(line.split("%", 1)[0] for line in text.splitlines())
def _balanced_lily_block(text: str, open_index: int) -> tuple[str, int]:
depth = 0
start = None
i = open_index
while i < len(text):
char = text[i]
if char == "{":
depth += 1
if start is None:
start = i + 1
elif char == "}":
depth -= 1
if depth == 0 and start is not None:
return text[start:i], i + 1
i += 1
raise ValueError("Could not find a balanced LilyPond music block.")
def _extract_lilypond_music_text(text: str) -> tuple[str, bool]:
cleaned = _strip_lilypond_comments(text)
relative_match = re.search(r"\\relative\s+[a-g](?:is|es|isis|eses)?[,']*\s*\{", cleaned)
if relative_match:
block, _ = _balanced_lily_block(cleaned, relative_match.end() - 1)
return block, True
assignment_match = re.search(r"=\s*(?:\\(?:absolute|fixed)\s+[a-g]?[,']*\s*)?\{", cleaned)
if assignment_match:
block, _ = _balanced_lily_block(cleaned, assignment_match.end() - 1)
return block, False
first_block = cleaned.find("{")
if first_block >= 0:
block, _ = _balanced_lily_block(cleaned, first_block)
return block, False
return cleaned, False
def _expand_lilypond_repeats(text: str) -> str:
repeat_re = re.compile(r"\\repeat\s+unfold\s+(\d+)\s*\{")
while True:
match = repeat_re.search(text)
if not match:
return text
block, end = _balanced_lily_block(text, match.end() - 1)
count = max(0, min(128, int(match.group(1))))
text = text[:match.start()] + " ".join([block] * count) + text[end:]
def _lily_duration_to_quarters(value: str | None, dots: str = "") -> float | None:
if not value:
return None
denominator = int(value)
if denominator <= 0:
return None
base = 4.0 / denominator
addition = base / 2.0
for _ in dots:
base += addition
addition /= 2.0
return base
def _lily_pitch_to_midi(token: str, previous_midi: int | None = None, relative: bool = False) -> int:
match = re.fullmatch(r"([a-g])(isis|eses|is|es)?([,']*)", token)
if not match:
raise ValueError(f"Unsupported LilyPond pitch token: {token}")
step, accidental, octave_marks = match.groups()
semitones = {"c": 0, "d": 2, "e": 4, "f": 5, "g": 7, "a": 9, "b": 11}
accidental_offset = {"isis": 2, "is": 1, "eses": -2, "es": -1}.get(accidental or "", 0)
pitch_class = (semitones[step] + accidental_offset) % 12
if relative and previous_midi is not None:
octave = previous_midi // 12 - 1
candidates = [12 * (octave + delta + 1) + pitch_class for delta in range(-3, 4)]
midi = min(candidates, key=lambda candidate: abs(candidate - previous_midi))
else:
midi = 12 * (3 + 1) + pitch_class
midi += 12 * octave_marks.count("'")
midi -= 12 * octave_marks.count(",")
return max(0, min(127, midi))
def _read_chord_token(text: str, start: int) -> tuple[str, int]:
end = text.find(">", start + 1)
if end < 0:
raise ValueError("Unclosed LilyPond chord.")
i = end + 1
while i < len(text) and (text[i].isdigit() or text[i] == "."):
i += 1
return text[start:i], i
def _skip_lily_whitespace(text: str, index: int) -> int:
while index < len(text) and text[index].isspace():
index += 1
return index
def _skip_lily_quoted_string(text: str, index: int) -> int:
if index >= len(text) or text[index] != '"':
return index
index += 1
while index < len(text):
if text[index] == "\\":
index += 2
continue
if text[index] == '"':
return index + 1
index += 1
return index
def _skip_lily_scheme_expr(text: str, index: int) -> int:
index = _skip_lily_whitespace(text, index)
if index >= len(text):
return index
if text[index] == "#":
index += 1
if index < len(text) and text[index] == "'":
index += 1
if index < len(text) and text[index] == "#":
return min(len(text), index + 2)
if index < len(text) and text[index] == "(":
depth = 0
while index < len(text):
char = text[index]
if char == '"':
index = _skip_lily_quoted_string(text, index)
continue
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if depth <= 0:
return index + 1
index += 1
return index
while index < len(text) and not text[index].isspace() and text[index] not in "{}<>|[]()":
index += 1
return index
def _skip_lily_atom(text: str, index: int) -> int:
index = _skip_lily_whitespace(text, index)
if index >= len(text):
return index
if text[index] == '"':
return _skip_lily_quoted_string(text, index)
if text[index] == "#":
return _skip_lily_scheme_expr(text, index)
if text[index] == "\\":
match = re.match(r"\\[A-Za-z][A-Za-z-]*", text[index:])
return index + len(match.group(0)) if match else index + 1
while index < len(text) and not text[index].isspace() and text[index] not in "{}<>|[]()":
index += 1
return index
def _skip_lily_assignment_command(text: str, index: int) -> int:
# Skip commands such as "\override Beam.positions = #'(1 . 0)" without
# consuming the musical token that follows on the same line.
index = _skip_lily_whitespace(text, index)
while index < len(text):
char = text[index]
if char == "\n":
return index
if char == "=":
return _skip_lily_scheme_expr(text, index + 1)
if char == "#":
return _skip_lily_scheme_expr(text, index)
if char in "{}<>|":
return index
if char == "\\" and re.match(r"\\[A-Za-z][A-Za-z-]*", text[index:]):
return index
index += 1
return index
def _skip_lily_command_args(name: str, text: str, index: int) -> int:
if name == "\\time":
match = re.match(r"\s*(\d+/\d+)", text[index:])
return index + match.end() if match else index
if name == "\\key":
index = _skip_lily_atom(text, index)
return _skip_lily_atom(text, index)
if name in {"\\clef", "\\bar", "\\mark", "\\tempo", "\\transposition"}:
return _skip_lily_atom(text, index)
if name == "\\barNumberCheck":
return _skip_lily_scheme_expr(text, index)
if name in {"\\override", "\\revert", "\\set", "\\unset"}:
return _skip_lily_assignment_command(text, index)
if name == "\\tuplet":
match = re.match(r"\s*\d+/\d+\s*\d*", text[index:])
return index + match.end() if match else index
return index
def _parse_lilypond_music_to_events(text: str, *, relative: bool, progress_callback=None) -> tuple[list[tuple[list[int], float, float]], str | None]:
if progress_callback:
progress_callback(0.0)
music = _expand_lilypond_repeats(text)
if progress_callback:
progress_callback(0.08)
events: list[tuple[list[int], float, float]] = []
time_signature = None
offset = 0.0
last_duration = 1.0
previous_midi = 60
i = 0
next_progress_at = max(1000, len(music) // 20)
while i < len(music):
if progress_callback and i >= next_progress_at:
progress_callback(0.08 + 0.90 * (i / max(1, len(music))))
next_progress_at += max(1000, len(music) // 20)
char = music[i]
if char.isspace() or char in "|[]()~":
i += 1
continue
if char == '"':
i = _skip_lily_quoted_string(music, i)
continue
if char == "#":
i = _skip_lily_scheme_expr(music, i)
continue
if music.startswith("<<", i) or music.startswith(">>", i):
i += 2
continue
if music.startswith("\\\\", i):
i += 2
continue
if char == "\\":
command = re.match(r"\\[A-Za-z][A-Za-z-]*", music[i:])
if not command:
i += 1
continue
name = command.group(0)
i += len(name)
if name == "\\time":
match = re.match(r"\s*(\d+/\d+)", music[i:])
if match:
time_signature = match.group(1)
i = _skip_lily_command_args(name, music, i)
continue
if char == "<":
token, i = _read_chord_token(music, i)
match = re.fullmatch(r"<([^>]+)>(\d+)?(\.*)", token)
if not match:
continue
pitch_tokens, duration_token, dots = match.groups()
duration = _lily_duration_to_quarters(duration_token, dots) or last_duration
midis = []
for pitch_token in re.findall(r"[a-g](?:isis|eses|is|es)?[,']*", pitch_tokens):
midi = _lily_pitch_to_midi(pitch_token, previous_midi, relative)
midis.append(midi)
if midis:
events.append((midis, offset, duration))
previous_midi = midis[-1]
offset += duration
last_duration = duration
continue
match = re.match(r"(R|[a-g](?:isis|eses|is|es)?[,']*|[rs])(\d+)?(\.*)(?:\*(\d+))?", music[i:])
if match:
pitch_token, duration_token, dots, multiplier_token = match.groups()
i += match.end()
duration = _lily_duration_to_quarters(duration_token, dots) or last_duration
multiplier = int(multiplier_token or "1")
if pitch_token not in {"R", "r", "s"}:
midi = _lily_pitch_to_midi(pitch_token, previous_midi, relative)
events.append(([midi], offset, duration))
previous_midi = midi
offset += duration * max(1, multiplier)
last_duration = duration
continue
i += 1
if progress_callback:
progress_callback(1.0)
return events, time_signature
def convert_lilypond_parts_to_musicxml(sources: list[str], out_dir: str, score_name: str, progress_callback=None) -> str:
if not sources:
raise RuntimeError("No .ily files were found to import.")
def progress(percent: int, message: str):
if progress_callback:
progress_callback(max(0, min(99, int(percent))), message)
score = stream.Score(id=score_name)
score.metadata = metadata.Metadata()
score.metadata.title = humanize_score_title(score_name)
first_time_signature = None
total_sources = len(sources)
for index, source in enumerate(sources):
source_label = os.path.basename(source)
progress(35 + int((index / max(1, total_sources)) * 25), f"Reading LilyPond part {index + 1}/{total_sources}: {source_label}")
with open(source, encoding="utf-8-sig") as f:
text = f.read()
music_text, is_relative = _extract_lilypond_music_text(text)
start_percent = 35 + int((index / max(1, total_sources)) * 25)
end_percent = 35 + int(((index + 1) / max(1, total_sources)) * 25)
events, time_signature = _parse_lilypond_music_to_events(
music_text,
relative=is_relative,
progress_callback=lambda fraction, label=source_label, start=start_percent, end=end_percent: progress(
start + int((end - start) * fraction),
f"Parsing {label} ({int(fraction * 100)}%)",
),
)
if not events:
raise RuntimeError(f"No playable notes were found in {os.path.basename(source)}.")
part = stream.Part(id=f"P{index + 1}")
part.partName = lilypond_part_name_from_path(source)
if time_signature and not first_time_signature:
first_time_signature = time_signature
if time_signature:
part.insert(0, meter.TimeSignature(time_signature))
elif first_time_signature:
part.insert(0, meter.TimeSignature(first_time_signature))
for midis, offset, duration in events:
if len(midis) == 1:
event_pitch = pitch.Pitch()
event_pitch.midi = midis[0]
event = note.Note(event_pitch)
else:
event = chord.Chord(midis)
event.quarterLength = duration
part.insert(offset, event)
score.insert(0, part)
progress(35 + int(((index + 1) / max(1, total_sources)) * 25), f"Parsed {source_label} ({len(events)} events)")
out_path = os.path.join(out_dir, f"{score_name}__lilypond.musicxml")
os.makedirs(out_dir, exist_ok=True)
progress(64, "Writing LilyPond MusicXML")
return score.write("musicxml", fp=out_path)
def lilypond_part_name_from_path(source: str) -> str:
stem = os.path.splitext(os.path.basename(source))[0]
stem = re.sub(r"^\d+[_\-\s]+", "", stem)
return humanize_score_title(stem)
def _xml_local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
def _remove_harmony_functions(xml_text: str) -> tuple[str, int]:
root = ET.fromstring(xml_text)
removed = 0
for parent in root.iter():
for child in list(parent):
if _xml_local_name(child.tag) != "harmony":
continue
has_function = any(
_xml_local_name(desc.tag) == "function" for desc in child.iter()
)
if has_function:
parent.remove(child)
removed += 1
return ET.tostring(root, encoding="unicode", xml_declaration=True), removed
def _musicxml_rootfile_from_mxl(source: str) -> str:
with zipfile.ZipFile(source) as zf:
try:
container_text = zf.read("META-INF/container.xml")
except KeyError:
container_text = b""
if container_text:
container = ET.fromstring(container_text)
for el in container.iter():
if _xml_local_name(el.tag) == "rootfile":
full_path = el.attrib.get("full-path")
if full_path:
return full_path
for name in zf.namelist():
if name.lower().endswith((".xml", ".musicxml")) and not name.startswith("META-INF/"):
return name
raise ValueError("Could not find a MusicXML score inside the uploaded MXL file.")
def _read_musicxml_text(source: str) -> str:
if zipfile.is_zipfile(source):
rootfile = _musicxml_rootfile_from_mxl(source)
with zipfile.ZipFile(source) as zf:
return zf.read(rootfile).decode("utf-8-sig")
with open(source, encoding="utf-8-sig") as f:
return f.read()
def _materialize_musicxml_text_source(source: str, out_dir: str, score_name: str) -> str:
if not zipfile.is_zipfile(source):
return source
output_path = os.path.join(out_dir, f"{score_name}__extracted.musicxml")
xml_text = _read_musicxml_text(source)
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(xml_text)
return output_path
def _write_musicxml_without_harmony_functions(source: str, output_path: str) -> int:
xml_text = _read_musicxml_text(source)
sanitized_xml, removed_count = _remove_harmony_functions(xml_text)
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(sanitized_xml)
return removed_count
def _parse_uploaded_musicxml(source: str, fallback_musicxml_path: str | None = None):
try:
return converter.parse(source), source, False
except Exception as exc:
if "not a valid pitch specification" not in str(exc):
raise
temp_dir = None
sanitized_path = fallback_musicxml_path
if sanitized_path is None:
temp_dir = tempfile.TemporaryDirectory(prefix="accompy_musicxml_")
sanitized_path = os.path.join(temp_dir.name, "score_without_harmony_functions.musicxml")
try:
removed_count = _write_musicxml_without_harmony_functions(source, sanitized_path)
if removed_count == 0:
if temp_dir is not None:
temp_dir.cleanup()
raise exc
score = converter.parse(sanitized_path)
except Exception:
if temp_dir is not None:
temp_dir.cleanup()
raise exc
render_source = sanitized_path if fallback_musicxml_path else source
if temp_dir is not None:
temp_dir.cleanup()
return score, render_source, True
def convert_score_source(source: str, *, name: str | None = None, out_dir: str | None = None):
out_dir = out_dir or str(get_scores_dir())
title_fallback = humanize_score_title(source)
score_name = slugify_score_name(name or title_fallback)
if source.startswith("corpus:"):
from music21 import corpus as m21corpus
corpus_path = source[len("corpus:"):]
mxl_path = str(m21corpus.getWork(corpus_path))
score = m21corpus.parse(corpus_path)
render_source_path = mxl_path
used_parse_fallback = False
else:
if is_musescore_file(source):
mxl_path = convert_musescore_to_musicxml(source, out_dir, score_name)
elif is_lilypond_file(source):
mxl_path = convert_lilypond_parts_to_musicxml([source], out_dir, score_name)
else:
mxl_path = source
fallback_musicxml_path = os.path.join(out_dir, f"{score_name}__sanitized.musicxml")
score, render_source_path, used_parse_fallback = _parse_uploaded_musicxml(
mxl_path,
fallback_musicxml_path=fallback_musicxml_path,
)
playback_score = score_for_playback(score)
parts_data = build_parts_data(playback_score)
measure_beats = extract_measure_beats(score)
title = score.metadata.title if score.metadata and score.metadata.title else title_fallback
out_py = os.path.join(out_dir, f"{score_name}.py")
out_html = os.path.join(out_dir, f"{score_name}.html")
# Normalize uploaded MusicXML through music21 before sending it to Verovio.
# Some raw .xml inputs parse fine in music21 but render poorly or blank in
# Verovio until they are re-exported into canonical MusicXML.
if not source.startswith("corpus:") and not used_parse_fallback:
normalized_musicxml = os.path.join(out_dir, f"{score_name}__normalized.musicxml")
try:
render_source_path = score.write("musicxml", fp=normalized_musicxml)
except Exception:
render_source_path = mxl_path
render_source_path = _materialize_musicxml_text_source(str(render_source_path), out_dir, score_name)
write_score_py(parts_data, out_py, title, source_ref=source, measure_beats=measure_beats)
render_html(str(render_source_path), out_html, title)
total_notes = sum(len(p["notes"]) for p in parts_data)
return {
"name": score_name,
"title": title,
"parts": parts_data,
"parts_count": len(parts_data),
"total_notes": total_notes,
"out_py": out_py,
"out_html": out_html,
"has_sheet": os.path.exists(out_html),
"source_ref": source,
"measure_beats": measure_beats,
"render_source_path": str(render_source_path),
"fingering": build_fingering_state(parts_data),
}
def _detect_instrument(part) -> str:
"""Infer an instrument name from a music21 part."""
from music21 import instrument as m21i
name = (part.partName or "").lower()
# Prefer explicit part-name cues for imported LilyPond/MusicXML files where
# music21 may not attach a concrete Instrument object.
for kw, result in [
("piano", "piano"),
("contrabass", "contrabass"),
("contrabasses", "contrabass"),
("contrabassi", "contrabass"),
("double bass", "contrabass"),
("bassi", "contrabass"),
("basso", "contrabass"),
("violin", "violin"),
("violino", "violin"),
("viola", "viola"),
("cello", "cello"),
("violoncello", "cello"),
("bassoon", "bassoon"),
("fagotti", "bassoon"),
("fagotto", "bassoon"),
("trumpet", "trumpet"),
("trombe", "trumpet"),
("tromba", "trumpet"),
("trombone", "trombone"),
("tromboni", "trombone"),
("trombono", "trombone"),
("tuba", "tuba"),
("timpani", "timpani"),
("timpano", "timpani"),
("horn", "horn"),
("corni", "horn"),
("corno", "horn"),
("clarinet", "clarinet"),
("clarinetti", "clarinet"),
("clarinetto", "clarinet"),
("flute", "flute"),
("flauti", "flute"),
("flauto", "flute"),
("oboe", "oboe"),
("oboi", "oboe"),
("soprano", "flute"),
("alto", "clarinet"),
("tenor", "violin"),
("bass", "contrabass"),
]:
if kw in name:
return result
try:
instr = part.getInstrument()
except Exception:
instr = None
if instr is None:
return "piano"
# Use class hierarchy first (most reliable)
if isinstance(instr, m21i.KeyboardInstrument):
return "piano"
if isinstance(instr, (m21i.Violin,)):
return "violin"
if isinstance(instr, (m21i.Viola,)):
return "viola"
if isinstance(instr, (m21i.Violoncello,)):
return "cello"
if isinstance(instr, (getattr(m21i, "Contrabass", tuple()),)):
return "contrabass"
if isinstance(instr, m21i.StringInstrument):
return "strings"
if isinstance(instr, (m21i.Flute,)):
return "flute"
if isinstance(instr, (m21i.Clarinet,)):
return "clarinet"
if isinstance(instr, (m21i.Oboe,)):
return "oboe"
if isinstance(instr, (m21i.Bassoon,)):
return "bassoon"
if isinstance(instr, (m21i.Trumpet,)):
return "trumpet"
if isinstance(instr, (m21i.Trombone,)):
return "trombone"
if isinstance(instr, (m21i.Tuba,)):
return "tuba"
if isinstance(instr, (m21i.Horn,)):
return "horn"
if isinstance(instr, (m21i.Timpani,)):
return "timpani"
if isinstance(instr, m21i.WoodwindInstrument):
return "clarinet"
if isinstance(instr, m21i.BrassInstrument):
return "horn"
return "piano"
def main():
parser = argparse.ArgumentParser(description="Convert MusicXML to an accompy score")
parser.add_argument("input", help="Path to .mxl/.xml/.mscx file, or corpus:<path>")
parser.add_argument("--name", help="Score name (default: auto-derived from input)")
parser.add_argument("--out", help="Explicit output path (overrides --name and default scores folder)")
args = parser.parse_args()
print(f"Parsing {args.input} ...")
out_dir = str(get_scores_dir())
out_name = args.name
if args.out:
out_dir = os.path.dirname(args.out) or "."
out_name = os.path.splitext(os.path.basename(args.out))[0]
try:
result = convert_score_source(args.input, name=out_name, out_dir=out_dir)
except Exception as exc:
print(str(exc))
sys.exit(1)
print(f"\nPlay it with: python main.py --score {result['name']}")
def render_html(mxl_path: str, out_path: str, title: str):
"""Render MusicXML to a printable HTML file using Verovio."""
try:
import verovio
except ImportError:
print("(Skipping sheet music render — run: pip install verovio)")
return
tk = verovio.toolkit()
tk.setOptions({
"pageWidth": 2100,
"pageHeight": 2970, # A4 in tenths (~210mm × 297mm at 10 tenths/mm)
"spacingSystem": 12,
"adjustPageHeight": 0,
"footer": "none",
})
tk.loadFile(mxl_path)
page_count = tk.getPageCount()
if page_count <= 0:
return
svgs = [tk.renderToSVG(i + 1) for i in range(page_count)]
non_empty_svgs = [svg for svg in svgs if svg and "<svg" in svg and len(svg) > 500]
if not non_empty_svgs:
return
page_divs = "\n".join(
f'<div class="page">{svg}</div>' for svg in non_empty_svgs
)
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{title}</title>
<style>
:root {{
color-scheme: light;
--notepilot-sheet-outer: #f4f1ea;
--notepilot-sheet-page: #ffffff;
--notepilot-sheet-ink: #111318;
--notepilot-sheet-shadow: 0 2px 6px rgba(0,0,0,.3);
}}
:root[data-notepilot-theme="dark"] {{
color-scheme: dark;
--notepilot-sheet-outer: #0f0f13;
--notepilot-sheet-page: #181824;
--notepilot-sheet-ink: #f2efe8;
--notepilot-sheet-shadow: 0 6px 18px rgba(0,0,0,.55);
}}
body {{ margin: 0; background: var(--notepilot-sheet-outer); color: var(--notepilot-sheet-ink); font-family: sans-serif; }}
h1 {{ text-align: center; padding: 1rem; font-size: 1.1rem; color: var(--notepilot-sheet-ink); }}
.page {{
background: var(--notepilot-sheet-page);
width: 210mm;
margin: 1rem auto;
box-shadow: var(--notepilot-sheet-shadow);
page-break-after: always;
}}
.page svg {{ width: 100%; height: auto; display: block; color: var(--notepilot-sheet-ink); }}
:root[data-notepilot-theme="dark"] svg :is(path, ellipse, polygon, polyline, line, text, tspan, use):not(.accompy-measure-highlight) {{
fill: var(--notepilot-sheet-ink) !important;
stroke: var(--notepilot-sheet-ink) !important;
}}
:root[data-notepilot-theme="dark"] svg rect:not(.accompy-measure-highlight) {{
fill: var(--notepilot-sheet-ink) !important;
stroke: var(--notepilot-sheet-ink) !important;
}}
:root[data-notepilot-theme="dark"] svg [fill="none"] {{ fill: none !important; }}
:root[data-notepilot-theme="dark"] svg [stroke="none"] {{ stroke: none !important; }}
:root[data-notepilot-theme="dark"] svg rect[fill="white"],
:root[data-notepilot-theme="dark"] svg rect[fill="#ffffff"],
:root[data-notepilot-theme="dark"] svg rect[fill="#FFF"],
:root[data-notepilot-theme="dark"] svg rect[fill="#fff"] {{
fill: var(--notepilot-sheet-page) !important;
stroke: var(--notepilot-sheet-page) !important;
}}
@media print {{
body {{ background: white; }}
h1 {{ display: none; }}
.page {{ margin: 0; box-shadow: none; width: 100%; }}
}}
</style>
</head>
<body>
{page_divs}
</body>
</html>"""
with open(out_path, "w") as f:
f.write(html)
print(f"Sheet music : {out_path} (open in browser → File → Print → Save as PDF)")
def show_melody(name: str = None):
"""Print the RIGHT_HAND from the current scores dir as note names + keyboard keys."""
import importlib.util, os
if not name:
print("Usage: python convert_score.py --show <score_name>")
sys.exit(1)
path = os.path.join(str(get_scores_dir()), f"{name}.py")
if not os.path.exists(path):
print(f"Score not found: {path}")
sys.exit(1)
spec = importlib.util.spec_from_file_location("_score", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
RIGHT_HAND = mod.RIGHT_HAND
_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
KEY_TO_PITCH = {
'z':48,'x':50,'c':52,'v':53,'b':55,'n':57,'m':59,
'a':60,'s':62,'d':64,'f':65,'g':67,'h':69,'j':71,
'q':72,'w':74,'e':76,'r':77,'t':79,'y':81,'u':83,
'i':84,
}
PITCH_TO_KEY = {v: k for k, v in KEY_TO_PITCH.items()}
def pitch_name(midi):
return _NAMES[midi % 12] + str((midi // 12) - 1)
print("\nKeyboard layout:")
print(" Low (C3–B3): z=C3 x=D3 c=E3 v=F3 b=G3 n=A3 m=B3")
print(" Mid (C4–B4): a=C4 s=D4 d=E4 f=F4 g=G4 h=A4 j=B4")
print(" High (C5–B5): q=C5 w=D5 e=E5 r=F5 t=G5 y=A5 u=B5 i=C6")
print()
print("Right-hand melody:")
print(f" {'Beat':>6} {'Note':>5} Key")
print(f" {'----':>6} {'----':>5} ---")
for event in RIGHT_HAND:
pitch, beat = event[0], event[1]
name = pitch_name(pitch)
key = PITCH_TO_KEY.get(pitch, '?')
print(f" {beat:>6.2f} {name:>5} {key}")
if __name__ == "__main__":
if "--show" in sys.argv:
# Accept optional score name: --show mozart_k545
idx = sys.argv.index("--show")
name = sys.argv[idx + 1] if idx + 1 < len(sys.argv) and not sys.argv[idx + 1].startswith("--") else None
show_melody(name)
else:
main()