Initial commit
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Audiveris service: PDF/image to MusicXML via Audiveris.
|
||||
|
||||
Audiveris is detected automatically in three locations (in order):
|
||||
1. backend/bin/audiveris.jar - legacy standalone JAR (any platform)
|
||||
2. /Applications/Audiveris.app - macOS DMG install
|
||||
3. `audiveris` in PATH - Linux .deb / Windows .msi install
|
||||
|
||||
Get Audiveris at: https://github.com/Audiveris/audiveris/releases
|
||||
Requires Java 17+ (already present on this machine).
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# ── Audiveris binary detection ────────────────────────────────────────────────
|
||||
|
||||
_LEGACY_JAR = Path(__file__).parent.parent / "bin" / "audiveris.jar"
|
||||
|
||||
_MACOS_APP_BIN = Path("/Applications/Audiveris.app/Contents/MacOS/Audiveris")
|
||||
|
||||
|
||||
def _find_audiveris() -> list[str] | None:
|
||||
"""Return the command prefix to invoke Audiveris, or None if not found.
|
||||
|
||||
Checked in order:
|
||||
1. backend/bin/audiveris.jar (legacy standalone JAR)
|
||||
2. /Applications/Audiveris.app (macOS DMG install)
|
||||
3. `audiveris` in PATH (Linux deb / Windows msi)
|
||||
"""
|
||||
if _LEGACY_JAR.exists():
|
||||
return ["java", "-jar", str(_LEGACY_JAR)]
|
||||
if _MACOS_APP_BIN.exists():
|
||||
return [str(_MACOS_APP_BIN)]
|
||||
if shutil.which("audiveris"):
|
||||
return ["audiveris"]
|
||||
return None
|
||||
|
||||
|
||||
# ── Timeouts (configurable via env vars) ──────────────────────────────────────
|
||||
|
||||
# Audiveris: typically 30-120 s per page; set higher for very large scores.
|
||||
AUDIVERIS_TIMEOUT = int(os.environ.get("AUDIVERIS_TIMEOUT_S", "120"))
|
||||
|
||||
# H4 - extension allowlist (defence-in-depth; routers also enforce this)
|
||||
_ALLOWED_SUFFIXES = {".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".tif"}
|
||||
|
||||
|
||||
# ── Public async entry points ─────────────────────────────────────────────────
|
||||
|
||||
async def pdf_to_musicxml(file_bytes: bytes, suffix: str = ".pdf") -> str:
|
||||
"""Run Audiveris OMR on a PDF or image, returns MusicXML string."""
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
None, _run_audiveris, file_bytes, suffix
|
||||
)
|
||||
|
||||
|
||||
async def pdf_to_musicxml_and_midi(
|
||||
file_bytes: bytes, suffix: str = ".pdf"
|
||||
) -> tuple[str, bytes]:
|
||||
"""OMR to MusicXML, then to MIDI.
|
||||
|
||||
Runs both steps in a single thread-pool task (both are blocking) to
|
||||
avoid two executor round-trips and to keep the intermediate MusicXML
|
||||
in memory rather than writing it back over the wire.
|
||||
|
||||
Returns:
|
||||
(musicxml_str, midi_bytes)
|
||||
"""
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
None, _run_omr_and_convert, file_bytes, suffix
|
||||
)
|
||||
|
||||
|
||||
# ── Combined OMR + MIDI conversion (blocking, runs in thread pool) ───────────
|
||||
|
||||
def _run_omr_and_convert(file_bytes: bytes, suffix: str) -> tuple[str, bytes]:
|
||||
musicxml = _run_audiveris(file_bytes, suffix)
|
||||
# Import the private sync helper directly to avoid a second executor call.
|
||||
from services.music21_svc import _xml_to_midi # noqa: PLC0415
|
||||
midi_bytes = _xml_to_midi(musicxml.encode("utf-8"), "score.xml")
|
||||
return musicxml, midi_bytes
|
||||
|
||||
|
||||
# ── Audiveris (primary engine) ────────────────────────────────────────────────
|
||||
|
||||
def _run_audiveris(file_bytes: bytes, suffix: str) -> str:
|
||||
# H4 - validate suffix even if caller skips router-level check
|
||||
if suffix.lower() not in _ALLOWED_SUFFIXES:
|
||||
raise ValueError(f"Unsupported file type: {suffix!r}")
|
||||
|
||||
cmd_prefix = _find_audiveris()
|
||||
if cmd_prefix is None:
|
||||
# T3-2: generic message for the API consumer; full setup details are
|
||||
# logged server-side only to avoid leaking OS/path information.
|
||||
import logging as _log
|
||||
_log.getLogger(__name__).error(
|
||||
"Audiveris not found. Checked: %s, %s, PATH",
|
||||
_LEGACY_JAR, _MACOS_APP_BIN,
|
||||
)
|
||||
raise RuntimeError("OMR engine is not available on this server")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
input_path = Path(tmpdir) / f"input{suffix}"
|
||||
input_path.write_bytes(file_bytes)
|
||||
output_dir = Path(tmpdir) / "output"
|
||||
output_dir.mkdir()
|
||||
|
||||
result = subprocess.run(
|
||||
cmd_prefix + [
|
||||
"-batch",
|
||||
"-export",
|
||||
"-output", str(output_dir),
|
||||
"--", str(input_path),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=AUDIVERIS_TIMEOUT,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
# H3 - don't leak internal tool output in API response
|
||||
raise RuntimeError("Audiveris OMR processing failed")
|
||||
|
||||
xml_files = list(output_dir.rglob("*.mxl")) + list(output_dir.rglob("*.xml"))
|
||||
if not xml_files:
|
||||
raise RuntimeError(
|
||||
"Audiveris produced no MusicXML output. "
|
||||
"The PDF may be a text document or a poor-quality scan."
|
||||
)
|
||||
|
||||
result_path = xml_files[0]
|
||||
if result_path.suffix.lower() == ".mxl":
|
||||
# .mxl is a ZIP-compressed MusicXML - extract the main XML entry
|
||||
import posixpath
|
||||
import zipfile
|
||||
|
||||
def _safe_member(name: str) -> bool:
|
||||
"""Reject ZIP entry names with path traversal sequences."""
|
||||
return not posixpath.normpath(name).startswith("..")
|
||||
|
||||
# T1-2: decompressed-size cap to defend against ZIP bombs.
|
||||
# Don't trust ZipInfo.file_size (headers can lie); enforce
|
||||
# the limit during actual streaming decompression.
|
||||
MAX_DECOMPRESSED = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
with zipfile.ZipFile(result_path) as zf:
|
||||
# META-INF/container.xml lists the root file; fall back to first .xml
|
||||
names = zf.namelist()
|
||||
root_file = next(
|
||||
(
|
||||
n for n in names
|
||||
if n.endswith(".xml")
|
||||
and not n.startswith("META-INF")
|
||||
and _safe_member(n)
|
||||
),
|
||||
next(
|
||||
(n for n in names if n.endswith(".xml") and _safe_member(n)),
|
||||
None,
|
||||
),
|
||||
)
|
||||
if root_file is None:
|
||||
raise RuntimeError("Audiveris .mxl archive contains no XML entry")
|
||||
|
||||
# Stream-read with size enforcement (T1-2)
|
||||
with zf.open(root_file) as entry:
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = entry.read(65_536)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_DECOMPRESSED:
|
||||
raise ValueError(
|
||||
"Decompressed MusicXML exceeds 50 MB, possible ZIP bomb"
|
||||
)
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks).decode("utf-8")
|
||||
else:
|
||||
return result_path.read_text(encoding="utf-8")
|
||||
Reference in New Issue
Block a user