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 converter, note, chord, expressions, pitch, dynamics as m21dynamics | |
| from src.fingering import build_fingering_state | |
| from src.paths import get_scores_dir | |
| MUSESCORE_SUFFIXES = {".mscx", ".mscz"} | |
| GRACE_NOTE_BEATS = 0.125 | |
| DYNAMIC_VELOCITY = { | |
| "ppp": 0.22, | |
| "pp": 0.30, | |
| "p": 0.38, | |
| "mp": 0.48, | |
| "mf": 0.60, | |
| "f": 0.74, | |
| "ff": 0.88, | |
| "fff": 1.00, | |
| } | |
| 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: | |
| continue | |
| try: | |
| beat = float(dynamic.getOffsetInHierarchy(anchor)) | |
| except Exception: | |
| beat = float(getattr(dynamic, "offset", 0.0) or 0.0) | |
| marks.append((beat, value, DYNAMIC_VELOCITY[value])) | |
| marks.sort(key=lambda item: item[0]) | |
| return marks | |
| def _dynamic_at_beat(dynamic_marks: list[tuple[float, str, float]], beat: float) -> dict | None: | |
| active = None | |
| for mark_beat, value, velocity in dynamic_marks: | |
| if mark_beat <= beat + 0.0001: | |
| active = (value, velocity) | |
| continue | |
| break | |
| if active is None: | |
| return None | |
| value, velocity = active | |
| return {"type": "dynamic", "mark": value, "velocity": velocity} | |
| 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): | |
| if not pitches: | |
| return | |
| slot = grouped.setdefault(beat, {"pitches": [], "duration": 0.0, "ornaments": []}) | |
| slot["pitches"].extend(pitches) | |
| slot["duration"] = max(slot["duration"], duration) | |
| if ornament: | |
| slot["ornaments"].append(ornament) | |
| 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) | |
| 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, | |
| ) | |
| 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, | |
| }, | |
| ) | |
| 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) | |
| 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], | |
| }, | |
| ) | |
| 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)) | |
| 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)) | |
| 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]) | |
| dynamic = _dynamic_at_beat(dynamic_marks, 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) | |
| 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) | |
| parts_data.append({"name": part_name, "instrument": instrument, "notes": notes}) | |
| return parts_data | |
| 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 _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 _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: | |
| mxl_path = ( | |
| convert_musescore_to_musicxml(source, out_dir, score_name) | |
| if is_musescore_file(source) | |
| else 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, | |
| ) | |
| parts_data = build_parts_data(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 | |
| 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 | |
| try: | |
| instr = part.getInstrument() | |
| except Exception: | |
| instr = None | |
| if instr is None: | |
| return "piano" | |
| name = (part.partName or "").lower() | |
| # 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, 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.WoodwindInstrument): | |
| return "flute" | |
| if isinstance(instr, m21i.BrassInstrument): | |
| return "strings" # approximate brass with strings for now | |
| # Fall back to part name keywords | |
| for kw, result in [ | |
| ("violin", "violin"), ("viola", "viola"), ("cello", "cello"), | |
| ("bass", "cello"), ("flute", "flute"), ("clarinet", "clarinet"), | |
| ("oboe", "oboe"), ("soprano", "flute"), ("alto", "clarinet"), | |
| ("tenor", "violin"), ("piano", "piano"), | |
| ]: | |
| if kw in name: | |
| return result | |
| 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() | |