""" 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 . 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:") 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 " 500] if not non_empty_svgs: return page_divs = "\n".join( f'
{svg}
' for svg in non_empty_svgs ) html = f""" {title} {page_divs} """ 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 ") 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()