"""
music21 service: MIDI to MusicXML, MusicXML to PDF
Note: defusedxml.defuse_stdlib() is called in main.py before this module
is imported, so xml.etree (used by music21 internally) is already patched.
"""
import asyncio
import concurrent.futures
import html
import logging
import os
import re
import tempfile
from collections.abc import Callable
from pathlib import Path
logger = logging.getLogger(__name__)
# T1-3: Dedicated process pool for music21 operations.
#
# Why ProcessPoolExecutor instead of ThreadPoolExecutor:
# Threads can't be forcibly killed when asyncio.wait_for fires; the
# timed-out thread keeps consuming CPU/memory until it naturally ends,
# occupying one of the two worker slots indefinitely. A separate OS
# process can be terminated, so a timed-out worker actually frees its slot.
#
# max_workers=2 caps concurrency so pathologically complex scores can't
# starve all other endpoints. Functions must be module-level (picklable),
# which they already are (_convert, _xml_to_midi, _to_pdf take only
# bytes/str arguments and return bytes/str).
#
# Initializer: On macOS / Linux with "spawn" start method each worker is a
# fresh process, defusedxml patches from main.py are NOT inherited.
# _music21_worker_init() re-applies them so XXE protection is active in
# every worker.
def _music21_worker_init() -> None:
"""Apply XML security patches in each worker process (spawn-safe)."""
import defusedxml as _defusedxml
_defusedxml.defuse_stdlib()
try:
from lxml import etree as _lxml_etree # type: ignore
_orig = _lxml_etree.XMLParser.__init__
def _safe_init(self, *args, **kwargs):
kwargs.setdefault("resolve_entities", False)
kwargs.setdefault("no_network", True)
_orig(self, *args, **kwargs)
_lxml_etree.XMLParser.__init__ = _safe_init
except ImportError:
pass # lxml not installed, nothing to patch
_music21_executor = concurrent.futures.ProcessPoolExecutor(
max_workers=2,
initializer=_music21_worker_init,
)
_MUSIC21_TIMEOUT = int(os.environ.get("MUSIC21_TIMEOUT_S", "60"))
async def _run_in_music21_pool(fn: Callable, *args) -> object:
"""Run a blocking music21 function in the dedicated pool with a hard timeout."""
try:
return await asyncio.wait_for(
asyncio.get_running_loop().run_in_executor(_music21_executor, fn, *args),
timeout=_MUSIC21_TIMEOUT,
)
except asyncio.TimeoutError:
raise RuntimeError(
"Processing timed out, the score may be too complex. "
"Try a simpler file or increase MUSIC21_TIMEOUT_S."
)
async def midi_bytes_to_musicxml(midi_bytes: bytes, title: str = "") -> str:
"""Convert MIDI binary to MusicXML string using music21."""
return await _run_in_music21_pool(_convert, midi_bytes, title)
def _convert(midi_bytes: bytes, title: str = "") -> str: # noqa: C901
import music21
from music21 import tempo as m21tempo
midi_path = None
xml_tmp_name = None
try:
# H5 - track paths so finally block can always clean up
with tempfile.NamedTemporaryFile(suffix=".mid", delete=False) as f:
f.write(midi_bytes)
midi_path = f.name
score = music21.converter.parse(midi_path)
# Quantize to 16th-note grid + triplets - eliminates exotic tuplets
try:
score = score.quantize(
quarterLengthDivisors=(4, 3),
processOffsets=True,
processDurations=True,
inPlace=False,
)
except Exception as e:
logger.warning("Score quantization skipped: %s", e)
# Strip excessive MetronomeMark objects from rubato performances
try:
all_marks = list(score.recurse().getElementsByClass("MetronomeMark"))
primary_bpm = round(all_marks[0].number) if all_marks else 120
for mark in all_marks:
if mark.activeSite is not None:
try:
mark.activeSite.remove(mark)
except Exception:
pass # mark already detached or activeSite stale
try:
first_part = score.parts[0]
measures = first_part.getElementsByClass("Measure")
if measures:
first_measure = list(measures)[0]
mm = m21tempo.MetronomeMark(number=primary_bpm)
first_measure.insert(0, mm)
except Exception as e:
logger.warning("Could not insert primary tempo mark: %s", e)
except Exception as e:
logger.warning("Tempo cleanup skipped: %s", e)
# Set score title, sanitize to prevent XML injection
try:
from music21 import metadata as m21meta
if score.metadata is None:
score.metadata = m21meta.Metadata()
safe_title = html.escape(title[:200]) if title else "Untitled"
score.metadata.title = safe_title
except Exception as e:
logger.warning("Could not set score title: %s", e)
# Split into treble/bass staves for proper piano rendering
score = _apply_piano_split(score)
xml_tmp = tempfile.NamedTemporaryFile(suffix=".xml", delete=False)
xml_tmp.close()
xml_tmp_name = xml_tmp.name
score.write("musicxml", fp=xml_tmp_name)
xml = Path(xml_tmp_name).read_text(encoding="utf-8")
# Strip music21-generated boilerplate that clutters the score display
xml = re.sub(r'\s*]*>[^<]*\s*\n?', '\n ', xml)
xml = re.sub(r'\s*]*>[^<]*\s*\n?', '\n ', xml)
return xml
finally:
if midi_path:
Path(midi_path).unlink(missing_ok=True)
if xml_tmp_name:
Path(xml_tmp_name).unlink(missing_ok=True)
def _apply_piano_split(score): # noqa: C901
"""Rewrite a MIDI-derived score as a proper piano grand staff.
Grand-staff piano in MusicXML must be ONE part with TWO staves
(staves>2, per-note tags, elements).
music21 achieves this via PartStaff + StaffGroup - which is what
AlphaTab needs to render treble + bass clef linked by a brace.
Strategy:
• Single-part score: split notes at C4 (MIDI 60) into treble/bass PartStaff.
• Multi-part score: treat the two highest-/lowest-pitched parts as
treble/bass and link them the same way.
"""
import copy
from music21 import clef as m21clef, instrument as m21inst, layout as m21layout
from music21 import note as m21note, chord as m21chord
from music21 import stream as m21s
parts = list(score.parts)
if not parts:
return score
SPLIT = 60 # Middle C - notes >= C4 go treble, < C4 go bass
def _avg_pitch(part):
pitches = []
for n in part.flatten().notes:
if hasattr(n, "pitches"):
pitches.extend(p.midi for p in n.pitches)
elif hasattr(n, "pitch"):
pitches.append(n.pitch.midi)
return sum(pitches) / len(pitches) if pitches else 60
# ── Build two PartStaff streams ──────────────────────────────────────────
treble_staff = m21s.PartStaff()
bass_staff = m21s.PartStaff()
treble_staff.insert(0, m21inst.Piano())
bass_staff.insert(0, m21inst.Piano())
def _has_pitched_content(m):
"""Return True if the Measure contains at least one Note, Rest, or Chord
at the top level OR nested inside Voice containers."""
if any(isinstance(e, (m21note.Note, m21note.Rest, m21chord.Chord)) for e in m):
return True
# Also check inside Voice containers
from music21 import stream as _m21s
for e in m:
if isinstance(e, _m21s.Voice):
if any(isinstance(v, (m21note.Note, m21note.Rest, m21chord.Chord)) for v in e):
return True
return False
if len(parts) == 1:
# Single-track MIDI: distribute notes by pitch while preserving Voice
# structure.
#
# Key design points:
# 1. Preserve Voice containers. Game/sequencer MIDIs nest notes inside
# Voice sub-streams. If we flatten and re-insert notes at the same
# offset into a plain Measure, music21 writes them SEQUENTIALLY
# (note A dur=2 + note B dur=0.5 = 2.5 beats consumed), inflating
# values and breaking AlphaTab's cursor tracker.
# Keeping Voice containers tells music21 that those notes are
# concurrent, so it emits correct values between voices.
# 2. Structural elements (TimeSignature, KeySignature, TempoIndication)
# go to the TREBLE staff only - putting them in both staves causes
# music21 to emit doubled