Spaces:
Runtime error
Runtime error
| """ | |
| 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() | |