Spaces:
Sleeping
Sleeping
Enhance app.py: Add support for MuseScore 3 binary, improve rendering process, and persist rendered images
Browse files
app.py
CHANGED
|
@@ -49,11 +49,22 @@ CPU_COUNT = os.cpu_count() or 1
|
|
| 49 |
MUSESCORE_APPIMAGE_URL = "https://www.modelscope.cn/studio/Genius-Society/piano_trans/resolve/master/MuseScore.AppImage"
|
| 50 |
MUSESCORE_STORAGE_DIR = Path("artifacts") / "musescore"
|
| 51 |
MUSESCORE_ENV_VAR = "MUSESCORE_BIN"
|
| 52 |
-
MUSESCORE_RENDER_TIMEOUT = int(os.environ.get("MUSESCORE_RENDER_TIMEOUT", "
|
| 53 |
MUSESCORE_EXTRACT_TIMEOUT = int(os.environ.get("MUSESCORE_EXTRACT_TIMEOUT", "240"))
|
| 54 |
_MUSESCORE_BINARY: Optional[str] = None
|
| 55 |
_MUSESCORE_READY: bool = False
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
# Global model variable
|
| 58 |
MODEL = None
|
| 59 |
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
|
@@ -295,17 +306,172 @@ def ensure_musescore_binary() -> Optional[str]:
|
|
| 295 |
return None
|
| 296 |
|
| 297 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
def initialize_musescore_backend() -> bool:
|
| 299 |
"""Initialize MuseScore AppRun at startup to avoid on-demand downloads."""
|
| 300 |
global _MUSESCORE_READY
|
| 301 |
if _MUSESCORE_READY:
|
| 302 |
return True
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
_MUSESCORE_READY = True
|
| 307 |
return True
|
| 308 |
-
logger.warning("MuseScore AppRun could
|
| 309 |
return False
|
| 310 |
|
| 311 |
|
|
@@ -355,42 +521,132 @@ def _coalesce_musescore_output(output_path: str) -> Optional[str]:
|
|
| 355 |
return str(normalized_path)
|
| 356 |
|
| 357 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
def render_with_musescore(musicxml_path: Optional[str], output_path: str) -> Optional[str]:
|
| 359 |
"""Render using MuseScore command-line interface."""
|
| 360 |
if not musicxml_path or not os.path.exists(musicxml_path):
|
| 361 |
return None
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
logger.debug("Executing MuseScore command: %s", " ".join(cmd))
|
| 372 |
-
try:
|
| 373 |
-
with log_timing("MuseScore rendering"):
|
| 374 |
-
subprocess.run(
|
| 375 |
-
cmd,
|
| 376 |
-
check=True,
|
| 377 |
-
stdout=subprocess.PIPE,
|
| 378 |
-
stderr=subprocess.PIPE,
|
| 379 |
-
env=env,
|
| 380 |
-
timeout=MUSESCORE_RENDER_TIMEOUT,
|
| 381 |
-
)
|
| 382 |
-
except subprocess.CalledProcessError as exc:
|
| 383 |
-
stderr = exc.stderr.decode(errors='ignore') if exc.stderr else str(exc)
|
| 384 |
-
logger.error("MuseScore rendering failed: %s", stderr)
|
| 385 |
-
return None
|
| 386 |
-
except subprocess.TimeoutExpired:
|
| 387 |
-
logger.error("MuseScore rendering timed out after %ss", MUSESCORE_RENDER_TIMEOUT)
|
| 388 |
return None
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
return None
|
| 395 |
|
| 396 |
|
|
@@ -630,8 +886,9 @@ def process_musicxml(
|
|
| 630 |
source_musicxml_path=source_path,
|
| 631 |
)
|
| 632 |
predictions = predict_analysis(model, score, selected_tasks)
|
| 633 |
-
if rendered_path
|
| 634 |
-
|
|
|
|
| 635 |
|
| 636 |
# Create DataFrame
|
| 637 |
if predictions:
|
|
@@ -684,7 +941,7 @@ def process_musicxml(
|
|
| 684 |
if parsed_score_path:
|
| 685 |
status_msg += " Parsed MusicXML ready for download."
|
| 686 |
|
| 687 |
-
return
|
| 688 |
|
| 689 |
except Exception as e:
|
| 690 |
error_msg = f"Error processing file: {str(e)}\n\n{traceback.format_exc()}"
|
|
|
|
| 49 |
MUSESCORE_APPIMAGE_URL = "https://www.modelscope.cn/studio/Genius-Society/piano_trans/resolve/master/MuseScore.AppImage"
|
| 50 |
MUSESCORE_STORAGE_DIR = Path("artifacts") / "musescore"
|
| 51 |
MUSESCORE_ENV_VAR = "MUSESCORE_BIN"
|
| 52 |
+
MUSESCORE_RENDER_TIMEOUT = int(os.environ.get("MUSESCORE_RENDER_TIMEOUT", "180"))
|
| 53 |
MUSESCORE_EXTRACT_TIMEOUT = int(os.environ.get("MUSESCORE_EXTRACT_TIMEOUT", "240"))
|
| 54 |
_MUSESCORE_BINARY: Optional[str] = None
|
| 55 |
_MUSESCORE_READY: bool = False
|
| 56 |
|
| 57 |
+
MUSESCORE_V3_APPIMAGE_URL = "https://github.com/musescore/MuseScore/releases/download/v3.6.2/MuseScore-3.6.2.548021370-x86_64.AppImage"
|
| 58 |
+
MUSESCORE_V3_STORAGE_DIR = Path("artifacts") / "musescore_v3"
|
| 59 |
+
MUSESCORE_V3_ENV_VAR = "MUSESCORE_V3_BIN"
|
| 60 |
+
_MUSESCORE_V3_BINARY: Optional[str] = None
|
| 61 |
+
|
| 62 |
+
RENDER_OUTPUT_DIR = Path("artifacts") / "rendered_scores"
|
| 63 |
+
|
| 64 |
+
XVFB_ENV_VAR = "XVFB_BIN"
|
| 65 |
+
XVFB_STORAGE_DIR = Path("artifacts") / "xvfb"
|
| 66 |
+
_XVFB_BINARY: Optional[str] = None
|
| 67 |
+
|
| 68 |
# Global model variable
|
| 69 |
MODEL = None
|
| 70 |
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
|
|
|
| 306 |
return None
|
| 307 |
|
| 308 |
|
| 309 |
+
def ensure_musescore_v3_binary() -> Optional[str]:
|
| 310 |
+
"""Ensure a MuseScore 3.x binary is available for rendering."""
|
| 311 |
+
global _MUSESCORE_V3_BINARY
|
| 312 |
+
if _MUSESCORE_V3_BINARY and os.path.exists(_MUSESCORE_V3_BINARY):
|
| 313 |
+
return _MUSESCORE_V3_BINARY
|
| 314 |
+
env_path = os.environ.get(MUSESCORE_V3_ENV_VAR)
|
| 315 |
+
if env_path and os.path.exists(env_path):
|
| 316 |
+
logger.info("Using MuseScore 3 binary from %s", MUSESCORE_V3_ENV_VAR)
|
| 317 |
+
_MUSESCORE_V3_BINARY = env_path
|
| 318 |
+
return _MUSESCORE_V3_BINARY
|
| 319 |
+
storage = MUSESCORE_V3_STORAGE_DIR
|
| 320 |
+
storage.mkdir(parents=True, exist_ok=True)
|
| 321 |
+
appimage_path = (storage / "MuseScore-3.AppImage").resolve(strict=False)
|
| 322 |
+
apprun_path = (storage / "squashfs-root" / "AppRun").resolve(strict=False)
|
| 323 |
+
if apprun_path.exists():
|
| 324 |
+
logger.info("Using cached MuseScore 3 AppRun at %s", apprun_path)
|
| 325 |
+
_MUSESCORE_V3_BINARY = str(apprun_path)
|
| 326 |
+
return _MUSESCORE_V3_BINARY
|
| 327 |
+
if not appimage_path.exists():
|
| 328 |
+
logger.info("MuseScore 3 AppImage missing. Downloading (first run only)...")
|
| 329 |
+
if not _download_file(MUSESCORE_V3_APPIMAGE_URL, appimage_path):
|
| 330 |
+
return None
|
| 331 |
+
try:
|
| 332 |
+
os.chmod(appimage_path, 0o755)
|
| 333 |
+
except Exception as exc:
|
| 334 |
+
logger.warning("Could not chmod MuseScore 3 AppImage: %s", exc)
|
| 335 |
+
try:
|
| 336 |
+
with log_timing("Extracting MuseScore 3 AppImage"):
|
| 337 |
+
subprocess.run(
|
| 338 |
+
[str(appimage_path), "--appimage-extract"],
|
| 339 |
+
cwd=storage,
|
| 340 |
+
check=True,
|
| 341 |
+
stdout=subprocess.PIPE,
|
| 342 |
+
stderr=subprocess.PIPE,
|
| 343 |
+
timeout=MUSESCORE_EXTRACT_TIMEOUT,
|
| 344 |
+
)
|
| 345 |
+
except subprocess.CalledProcessError as exc:
|
| 346 |
+
stderr = exc.stderr.decode(errors='ignore') if exc.stderr else str(exc)
|
| 347 |
+
logger.error("MuseScore 3 extraction failed: %s", stderr)
|
| 348 |
+
return None
|
| 349 |
+
except subprocess.TimeoutExpired:
|
| 350 |
+
logger.error("MuseScore 3 extraction timed out after %ss", MUSESCORE_EXTRACT_TIMEOUT)
|
| 351 |
+
return None
|
| 352 |
+
if apprun_path.exists():
|
| 353 |
+
_MUSESCORE_V3_BINARY = str(apprun_path)
|
| 354 |
+
try:
|
| 355 |
+
os.chmod(apprun_path, 0o755)
|
| 356 |
+
except Exception:
|
| 357 |
+
pass
|
| 358 |
+
logger.info("MuseScore 3 AppRun ready at %s", _MUSESCORE_V3_BINARY)
|
| 359 |
+
return _MUSESCORE_V3_BINARY
|
| 360 |
+
logger.error("MuseScore 3 extraction did not produce the expected AppRun binary.")
|
| 361 |
+
return None
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _download_xvfb_package(dest_dir: Path) -> Optional[Path]:
|
| 365 |
+
"""Download the Xvfb .deb package using apt."""
|
| 366 |
+
try:
|
| 367 |
+
completed = subprocess.run(
|
| 368 |
+
["apt", "download", "xvfb"],
|
| 369 |
+
cwd=str(dest_dir),
|
| 370 |
+
check=True,
|
| 371 |
+
stdout=subprocess.PIPE,
|
| 372 |
+
stderr=subprocess.PIPE,
|
| 373 |
+
text=True,
|
| 374 |
+
)
|
| 375 |
+
logger.debug("apt download xvfb stdout: %s", completed.stdout.strip())
|
| 376 |
+
if completed.stderr:
|
| 377 |
+
logger.debug("apt download xvfb stderr: %s", completed.stderr.strip())
|
| 378 |
+
except FileNotFoundError:
|
| 379 |
+
logger.error("'apt' command not available; cannot download Xvfb automatically.")
|
| 380 |
+
return None
|
| 381 |
+
except subprocess.CalledProcessError as exc:
|
| 382 |
+
logger.error(
|
| 383 |
+
"Failed to download Xvfb package (exit %s): %s",
|
| 384 |
+
exc.returncode,
|
| 385 |
+
exc.stderr.strip() if exc.stderr else exc,
|
| 386 |
+
)
|
| 387 |
+
return None
|
| 388 |
+
deb_candidates = sorted(dest_dir.glob("xvfb_*.deb"), key=lambda p: p.stat().st_mtime, reverse=True)
|
| 389 |
+
if not deb_candidates:
|
| 390 |
+
logger.error("apt download xvfb did not produce any .deb files under %s", dest_dir)
|
| 391 |
+
return None
|
| 392 |
+
return deb_candidates[0]
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def _extract_xvfb_binary(deb_path: Path, target_dir: Path) -> Optional[Path]:
|
| 396 |
+
extract_dir = target_dir / "pkg"
|
| 397 |
+
if extract_dir.exists():
|
| 398 |
+
shutil.rmtree(extract_dir, ignore_errors=True)
|
| 399 |
+
try:
|
| 400 |
+
subprocess.run(
|
| 401 |
+
["dpkg-deb", "-x", str(deb_path), str(extract_dir)],
|
| 402 |
+
check=True,
|
| 403 |
+
stdout=subprocess.PIPE,
|
| 404 |
+
stderr=subprocess.PIPE,
|
| 405 |
+
)
|
| 406 |
+
except FileNotFoundError:
|
| 407 |
+
logger.error("'dpkg-deb' command not available; cannot extract Xvfb package.")
|
| 408 |
+
return None
|
| 409 |
+
except subprocess.CalledProcessError as exc:
|
| 410 |
+
stderr = exc.stderr.decode(errors="ignore") if isinstance(exc.stderr, bytes) else exc.stderr
|
| 411 |
+
logger.error("Failed to extract Xvfb package: %s", stderr or exc)
|
| 412 |
+
return None
|
| 413 |
+
xvfb_path = extract_dir / "usr/bin/Xvfb"
|
| 414 |
+
if xvfb_path.exists():
|
| 415 |
+
logger.info("Xvfb binary extracted to %s", xvfb_path)
|
| 416 |
+
try:
|
| 417 |
+
os.chmod(xvfb_path, 0o755)
|
| 418 |
+
except Exception:
|
| 419 |
+
pass
|
| 420 |
+
try:
|
| 421 |
+
deb_path.unlink()
|
| 422 |
+
except Exception:
|
| 423 |
+
pass
|
| 424 |
+
return xvfb_path
|
| 425 |
+
logger.error("Extracted Xvfb package but could not find usr/bin/Xvfb inside %s", extract_dir)
|
| 426 |
+
return None
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def ensure_xvfb_binary() -> Optional[str]:
|
| 430 |
+
"""Ensure we have an Xvfb binary available for headless rendering."""
|
| 431 |
+
global _XVFB_BINARY
|
| 432 |
+
if _XVFB_BINARY and os.path.exists(_XVFB_BINARY):
|
| 433 |
+
return _XVFB_BINARY
|
| 434 |
+
env_path = os.environ.get(XVFB_ENV_VAR)
|
| 435 |
+
if env_path and os.path.exists(env_path):
|
| 436 |
+
_XVFB_BINARY = env_path
|
| 437 |
+
return _XVFB_BINARY
|
| 438 |
+
which = shutil.which("Xvfb")
|
| 439 |
+
if which:
|
| 440 |
+
_XVFB_BINARY = which
|
| 441 |
+
return _XVFB_BINARY
|
| 442 |
+
XVFB_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
| 443 |
+
extracted_bin = XVFB_STORAGE_DIR / "pkg" / "usr" / "bin" / "Xvfb"
|
| 444 |
+
if extracted_bin.exists():
|
| 445 |
+
_XVFB_BINARY = str(extracted_bin)
|
| 446 |
+
return _XVFB_BINARY
|
| 447 |
+
deb_path = _download_xvfb_package(XVFB_STORAGE_DIR)
|
| 448 |
+
if not deb_path:
|
| 449 |
+
return None
|
| 450 |
+
extracted = _extract_xvfb_binary(deb_path, XVFB_STORAGE_DIR)
|
| 451 |
+
if extracted:
|
| 452 |
+
_XVFB_BINARY = str(extracted)
|
| 453 |
+
return _XVFB_BINARY
|
| 454 |
+
return None
|
| 455 |
+
|
| 456 |
+
|
| 457 |
def initialize_musescore_backend() -> bool:
|
| 458 |
"""Initialize MuseScore AppRun at startup to avoid on-demand downloads."""
|
| 459 |
global _MUSESCORE_READY
|
| 460 |
if _MUSESCORE_READY:
|
| 461 |
return True
|
| 462 |
+
available = []
|
| 463 |
+
primary = ensure_musescore_binary()
|
| 464 |
+
if primary:
|
| 465 |
+
available.append(primary)
|
| 466 |
+
logger.info("MuseScore 4 AppRun ready at startup: %s", primary)
|
| 467 |
+
legacy = ensure_musescore_v3_binary()
|
| 468 |
+
if legacy:
|
| 469 |
+
available.append(legacy)
|
| 470 |
+
logger.info("MuseScore 3 AppRun ready at startup: %s", legacy)
|
| 471 |
+
if available:
|
| 472 |
_MUSESCORE_READY = True
|
| 473 |
return True
|
| 474 |
+
logger.warning("No MuseScore AppRun binaries could be initialized; score visualization will fail.")
|
| 475 |
return False
|
| 476 |
|
| 477 |
|
|
|
|
| 521 |
return str(normalized_path)
|
| 522 |
|
| 523 |
|
| 524 |
+
def persist_rendered_image(src_path: str) -> Optional[str]:
|
| 525 |
+
"""Copy rendered PNG to a persistent artifacts directory for UI display."""
|
| 526 |
+
if not src_path or not os.path.exists(src_path):
|
| 527 |
+
return None
|
| 528 |
+
try:
|
| 529 |
+
RENDER_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 530 |
+
dest = RENDER_OUTPUT_DIR / f"{int(time.time()*1000)}_{Path(src_path).name}"
|
| 531 |
+
shutil.copy2(src_path, dest)
|
| 532 |
+
return str(dest)
|
| 533 |
+
except Exception as exc:
|
| 534 |
+
logger.warning("Could not persist rendered image %s: %s", src_path, exc)
|
| 535 |
+
return src_path
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
@contextmanager
|
| 539 |
+
def xvfb_session():
|
| 540 |
+
"""Spin up a temporary Xvfb server if available."""
|
| 541 |
+
xvfb_bin = ensure_xvfb_binary()
|
| 542 |
+
if not xvfb_bin:
|
| 543 |
+
logger.warning("Xvfb binary unavailable; proceeding without virtual display.")
|
| 544 |
+
yield None
|
| 545 |
+
return
|
| 546 |
+
display = None
|
| 547 |
+
base_dir = Path("/tmp/.X11-unix")
|
| 548 |
+
try:
|
| 549 |
+
base_dir.mkdir(mode=0o1777, exist_ok=True)
|
| 550 |
+
except Exception:
|
| 551 |
+
pass
|
| 552 |
+
used = {p.name for p in base_dir.glob("X*")}
|
| 553 |
+
for candidate in range(99, 160):
|
| 554 |
+
name = f"X{candidate}"
|
| 555 |
+
if name not in used:
|
| 556 |
+
display = f":{candidate}"
|
| 557 |
+
break
|
| 558 |
+
if display is None:
|
| 559 |
+
logger.warning("No free DISPLAY slots for Xvfb.")
|
| 560 |
+
yield None
|
| 561 |
+
return
|
| 562 |
+
cmd = [
|
| 563 |
+
xvfb_bin,
|
| 564 |
+
display,
|
| 565 |
+
"-screen",
|
| 566 |
+
"0",
|
| 567 |
+
"1920x1080x24",
|
| 568 |
+
"-nolisten",
|
| 569 |
+
"tcp",
|
| 570 |
+
]
|
| 571 |
+
logger.debug("Starting Xvfb with command: %s", " ".join(cmd))
|
| 572 |
+
proc = subprocess.Popen(
|
| 573 |
+
cmd,
|
| 574 |
+
stdout=subprocess.DEVNULL,
|
| 575 |
+
stderr=subprocess.DEVNULL,
|
| 576 |
+
)
|
| 577 |
+
time.sleep(0.5)
|
| 578 |
+
if proc.poll() is not None:
|
| 579 |
+
logger.error("Xvfb failed to start (exit %s).", proc.returncode)
|
| 580 |
+
yield None
|
| 581 |
+
return
|
| 582 |
+
try:
|
| 583 |
+
yield display
|
| 584 |
+
finally:
|
| 585 |
+
proc.terminate()
|
| 586 |
+
try:
|
| 587 |
+
proc.wait(timeout=5)
|
| 588 |
+
except subprocess.TimeoutExpired:
|
| 589 |
+
proc.kill()
|
| 590 |
+
|
| 591 |
+
|
| 592 |
def render_with_musescore(musicxml_path: Optional[str], output_path: str) -> Optional[str]:
|
| 593 |
"""Render using MuseScore command-line interface."""
|
| 594 |
if not musicxml_path or not os.path.exists(musicxml_path):
|
| 595 |
return None
|
| 596 |
+
candidates = []
|
| 597 |
+
legacy = ensure_musescore_v3_binary()
|
| 598 |
+
if legacy:
|
| 599 |
+
candidates.append(("MuseScore 3", legacy, True))
|
| 600 |
+
primary = ensure_musescore_binary()
|
| 601 |
+
if primary:
|
| 602 |
+
candidates.append(("MuseScore 4", primary, True))
|
| 603 |
+
if not candidates:
|
| 604 |
+
logger.warning("No MuseScore binaries available for rendering.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
return None
|
| 606 |
+
last_error = None
|
| 607 |
+
for label, musescore_bin, requires_display in candidates:
|
| 608 |
+
env = os.environ.copy()
|
| 609 |
+
env.setdefault("QTWEBENGINE_DISABLE_SANDBOX", "1")
|
| 610 |
+
env.setdefault("MUSESCORE_NO_AUDIO", "1")
|
| 611 |
+
cmd = [musescore_bin, "-o", output_path, musicxml_path]
|
| 612 |
+
logger.info("Attempting rendering with %s (%s).", label, musescore_bin)
|
| 613 |
+
try:
|
| 614 |
+
with xvfb_session() as display:
|
| 615 |
+
if display:
|
| 616 |
+
env["DISPLAY"] = display
|
| 617 |
+
env["QT_QPA_PLATFORM"] = "xcb"
|
| 618 |
+
logger.debug("%s: using Xvfb display %s", label, display)
|
| 619 |
+
else:
|
| 620 |
+
if requires_display:
|
| 621 |
+
logger.warning("%s requires an X11 display but Xvfb could not be started.", label)
|
| 622 |
+
continue
|
| 623 |
+
env["QT_QPA_PLATFORM"] = "offscreen"
|
| 624 |
+
logger.debug("%s: using Qt offscreen platform.", label)
|
| 625 |
+
with log_timing(f"{label} rendering"):
|
| 626 |
+
subprocess.run(
|
| 627 |
+
cmd,
|
| 628 |
+
check=True,
|
| 629 |
+
stdout=subprocess.PIPE,
|
| 630 |
+
stderr=subprocess.PIPE,
|
| 631 |
+
env=env,
|
| 632 |
+
timeout=MUSESCORE_RENDER_TIMEOUT,
|
| 633 |
+
)
|
| 634 |
+
except subprocess.CalledProcessError as exc:
|
| 635 |
+
stderr = exc.stderr.decode(errors='ignore') if exc.stderr else str(exc)
|
| 636 |
+
logger.error("%s rendering failed: %s", label, stderr)
|
| 637 |
+
last_error = stderr
|
| 638 |
+
continue
|
| 639 |
+
except subprocess.TimeoutExpired:
|
| 640 |
+
logger.error("%s rendering timed out after %ss", label, MUSESCORE_RENDER_TIMEOUT)
|
| 641 |
+
last_error = f"{label} timed out"
|
| 642 |
+
continue
|
| 643 |
+
normalized_path = _coalesce_musescore_output(output_path)
|
| 644 |
+
if normalized_path and os.path.exists(normalized_path):
|
| 645 |
+
logger.info("%s rendered %s -> %s", label, musicxml_path, normalized_path)
|
| 646 |
+
return normalized_path
|
| 647 |
+
logger.error("%s rendered score but the expected output file was not found.", label)
|
| 648 |
+
last_error = "output missing"
|
| 649 |
+
logger.error("All MuseScore binaries failed to render the score. Last error: %s", last_error)
|
| 650 |
return None
|
| 651 |
|
| 652 |
|
|
|
|
| 886 |
source_musicxml_path=source_path,
|
| 887 |
)
|
| 888 |
predictions = predict_analysis(model, score, selected_tasks)
|
| 889 |
+
persisted_path = persist_rendered_image(rendered_path) if rendered_path else None
|
| 890 |
+
if rendered_path is None or persisted_path is None:
|
| 891 |
+
logger.warning("MuseScore AppRun could not render the score or save the PNG; visualization will be unavailable.")
|
| 892 |
|
| 893 |
# Create DataFrame
|
| 894 |
if predictions:
|
|
|
|
| 941 |
if parsed_score_path:
|
| 942 |
status_msg += " Parsed MusicXML ready for download."
|
| 943 |
|
| 944 |
+
return persisted_path, df, parsed_score_path, status_msg
|
| 945 |
|
| 946 |
except Exception as e:
|
| 947 |
error_msg = f"Error processing file: {str(e)}\n\n{traceback.format_exc()}"
|