""" 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 spanner as m21spanner 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