manoskary commited on
Commit
0410a37
·
1 Parent(s): 8d6029e

Enhance app.py: Add support for MuseScore 3 binary, improve rendering process, and persist rendered images

Browse files
Files changed (1) hide show
  1. app.py +296 -39
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", "120"))
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
- binary = ensure_musescore_binary()
304
- if binary:
305
- logger.info("MuseScore AppRun ready at startup: %s", binary)
 
 
 
 
 
 
 
306
  _MUSESCORE_READY = True
307
  return True
308
- logger.warning("MuseScore AppRun could not be initialized; score visualization will fail.")
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
- musescore_bin = ensure_musescore_binary()
363
- if musescore_bin is None:
364
- logger.warning("MuseScore binary unavailable; skipping MuseScore rendering.")
365
- return None
366
- env = os.environ.copy()
367
- env["QT_QPA_PLATFORM"] = "offscreen"
368
- env.setdefault("QTWEBENGINE_DISABLE_SANDBOX", "1")
369
- env.setdefault("MUSESCORE_NO_AUDIO", "1")
370
- cmd = [musescore_bin, "-o", output_path, musicxml_path]
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
- normalized_path = _coalesce_musescore_output(output_path)
390
- if normalized_path and os.path.exists(normalized_path):
391
- logger.info("MuseScore rendered %s -> %s", musicxml_path, normalized_path)
392
- return normalized_path
393
- logger.error("MuseScore rendered score but the expected output file was not found.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 is None:
634
- logger.warning("MuseScore AppRun could not render the score. Visualization will be unavailable.")
 
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 rendered_path, df, parsed_score_path, status_msg
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()}"