Spaces:
Sleeping
Sleeping
Enhance README and app.py: Improve score visualization and add MuseScore AppImage handling
Browse files
README.md
CHANGED
|
@@ -18,7 +18,7 @@ A Gradio web interface for [AnalysisGNN](https://github.com/manoskary/analysisGN
|
|
| 18 |
## Features
|
| 19 |
|
| 20 |
- πΌ **MusicXML Upload**: Upload and analyze musical scores in MusicXML format
|
| 21 |
-
- π¨ **Score Visualization**: Automatic rendering of uploaded scores to images
|
| 22 |
- π **Multi-task Analysis**: Perform various music analysis tasks:
|
| 23 |
- Cadence Detection
|
| 24 |
- Key Analysis (Local & Tonalized)
|
|
@@ -69,6 +69,10 @@ This app is designed to run on Hugging Face Spaces. Simply deploy it as a Gradio
|
|
| 69 |
- Analysis results table (note-level predictions)
|
| 70 |
5. **Download results** as CSV if needed
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
## Model
|
| 73 |
|
| 74 |
The app uses a pre-trained AnalysisGNN model automatically downloaded from Weights & Biases. The model is cached in the `./artifacts/` folder to avoid re-downloading.
|
|
|
|
| 18 |
## Features
|
| 19 |
|
| 20 |
- πΌ **MusicXML Upload**: Upload and analyze musical scores in MusicXML format
|
| 21 |
+
- π¨ **Score Visualization**: Automatic rendering of uploaded scores to images (now with built-in MuseScore AppImage fallback)
|
| 22 |
- π **Multi-task Analysis**: Perform various music analysis tasks:
|
| 23 |
- Cadence Detection
|
| 24 |
- Key Analysis (Local & Tonalized)
|
|
|
|
| 69 |
- Analysis results table (note-level predictions)
|
| 70 |
5. **Download results** as CSV if needed
|
| 71 |
|
| 72 |
+
### Score Rendering Backend
|
| 73 |
+
|
| 74 |
+
The interface first tries to render MusicXML scores with Partitura. If that backend is missing MuseScore/LilyPond, the app now mirrors the [manoskary/weavemuse](https://github.com/manoskary/weavemuse) approach: it automatically downloads and extracts the MuseScore AppImage (stored under `./artifacts/musescore/`) and calls it headlessly (`QT_QPA_PLATFORM=offscreen`). You can override the binary by setting `MUSESCORE_BIN=/path/to/mscore` before launching the app.
|
| 75 |
+
|
| 76 |
## Model
|
| 77 |
|
| 78 |
The app uses a pre-trained AnalysisGNN model automatically downloaded from Weights & Biases. The model is cached in the `./artifacts/` folder to avoid re-downloading.
|
app.py
CHANGED
|
@@ -9,9 +9,16 @@ Users can upload MusicXML scores, run the model, and view results.
|
|
| 9 |
import gradio as gr
|
| 10 |
import pandas as pd
|
| 11 |
import numpy as np
|
|
|
|
| 12 |
import os
|
|
|
|
|
|
|
| 13 |
import tempfile
|
|
|
|
| 14 |
import torch
|
|
|
|
|
|
|
|
|
|
| 15 |
from pathlib import Path
|
| 16 |
from typing import Tuple, Optional, Dict
|
| 17 |
import traceback
|
|
@@ -29,13 +36,63 @@ from analysisgnn.utils.chord_representations import available_representations, N
|
|
| 29 |
if "note_degree" not in available_representations and NoteDegree49 is not None:
|
| 30 |
available_representations["note_degree"] = NoteDegree49
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
# Global model variable
|
| 33 |
MODEL = None
|
| 34 |
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 35 |
|
| 36 |
-
|
| 37 |
if torch.cuda.is_available():
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
def download_wandb_checkpoint(artifact_path: str = "melkisedeath/AnalysisGNN/model-uvj2ddun:v1") -> str:
|
|
@@ -47,7 +104,7 @@ def download_wandb_checkpoint(artifact_path: str = "melkisedeath/AnalysisGNN/mod
|
|
| 47 |
# Check if checkpoint already exists directly in artifacts/models
|
| 48 |
checkpoint_path = os.path.join(artifacts_dir, "model.ckpt")
|
| 49 |
if os.path.exists(checkpoint_path):
|
| 50 |
-
|
| 51 |
return checkpoint_path
|
| 52 |
|
| 53 |
# Check for any .ckpt file in the artifacts/models directory
|
|
@@ -55,25 +112,26 @@ def download_wandb_checkpoint(artifact_path: str = "melkisedeath/AnalysisGNN/mod
|
|
| 55 |
for fname in os.listdir(artifacts_dir):
|
| 56 |
if fname.endswith('.ckpt'):
|
| 57 |
checkpoint_path = os.path.join(artifacts_dir, fname)
|
| 58 |
-
|
| 59 |
return checkpoint_path
|
| 60 |
|
| 61 |
# Check artifact-specific subdirectory
|
| 62 |
artifact_dir = os.path.join(artifacts_dir, os.path.basename(artifact_path))
|
| 63 |
checkpoint_path = os.path.join(artifact_dir, "model.ckpt")
|
| 64 |
if os.path.exists(checkpoint_path):
|
| 65 |
-
|
| 66 |
return checkpoint_path
|
| 67 |
|
| 68 |
# Only import and use wandb if checkpoint is not cached
|
| 69 |
import wandb
|
| 70 |
-
|
| 71 |
|
| 72 |
# Initialize wandb in offline mode to avoid creating online runs
|
| 73 |
run = wandb.init(mode="offline")
|
| 74 |
try:
|
| 75 |
artifact = run.use_artifact(artifact_path, type='model')
|
| 76 |
-
|
|
|
|
| 77 |
finally:
|
| 78 |
wandb.finish()
|
| 79 |
|
|
@@ -94,17 +152,248 @@ def load_model() -> ContinualAnalysisGNN:
|
|
| 94 |
|
| 95 |
if MODEL is None:
|
| 96 |
checkpoint_path = download_wandb_checkpoint()
|
| 97 |
-
|
| 98 |
MODEL = ContinualAnalysisGNN.load_from_checkpoint(
|
| 99 |
checkpoint_path,
|
| 100 |
map_location=DEVICE
|
| 101 |
)
|
| 102 |
MODEL.eval()
|
| 103 |
MODEL.to(DEVICE)
|
| 104 |
-
|
| 105 |
return MODEL
|
| 106 |
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
def resolve_musicxml_path(musicxml_file) -> Optional[str]:
|
| 109 |
"""Return a filesystem path for the uploaded MusicXML file."""
|
| 110 |
if musicxml_file is None:
|
|
@@ -133,57 +422,30 @@ def save_parsed_musicxml(score: pt.score.Score, original_path: Optional[str]) ->
|
|
| 133 |
suffix = original_suffix
|
| 134 |
fd, tmp_path = tempfile.mkstemp(suffix=suffix)
|
| 135 |
os.close(fd)
|
| 136 |
-
|
|
|
|
| 137 |
return tmp_path
|
| 138 |
except Exception as exc:
|
| 139 |
-
|
| 140 |
return None
|
| 141 |
|
| 142 |
|
| 143 |
-
def render_score_to_image(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
"""
|
| 145 |
-
Render score
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
score : pt.score.Score
|
| 150 |
-
The score to render
|
| 151 |
-
output_path : str
|
| 152 |
-
Path to save the rendered image
|
| 153 |
-
|
| 154 |
-
Returns
|
| 155 |
-
-------
|
| 156 |
-
str or None
|
| 157 |
-
Path to the rendered image, or None if rendering failed
|
| 158 |
"""
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
except Exception as e:
|
| 165 |
-
print(f"Error rendering score to PNG: {e}")
|
| 166 |
-
|
| 167 |
-
# If PNG rendering failed, try PDF
|
| 168 |
-
try:
|
| 169 |
-
pdf_path = output_path.replace('.png', '.pdf')
|
| 170 |
-
pt.render(score, fmt="pdf", out=pdf_path)
|
| 171 |
-
if os.path.exists(pdf_path):
|
| 172 |
-
# Convert PDF to PNG if possible
|
| 173 |
-
try:
|
| 174 |
-
from pdf2image import convert_from_path
|
| 175 |
-
images = convert_from_path(pdf_path)
|
| 176 |
-
if images:
|
| 177 |
-
images[0].save(output_path, 'PNG')
|
| 178 |
-
return output_path
|
| 179 |
-
except (ImportError, Exception) as e:
|
| 180 |
-
# If conversion fails, return the PDF path
|
| 181 |
-
print(f"PDF to PNG conversion failed: {e}")
|
| 182 |
-
print("Score rendered as PDF but could not be converted to PNG for visualization.")
|
| 183 |
-
except Exception as e:
|
| 184 |
-
print(f"Error rendering score to PDF: {e}")
|
| 185 |
-
|
| 186 |
-
return None
|
| 187 |
|
| 188 |
|
| 189 |
def predict_analysis(
|
|
@@ -209,8 +471,8 @@ def predict_analysis(
|
|
| 209 |
Dictionary mapping task names to predictions and confidence scores
|
| 210 |
"""
|
| 211 |
with torch.no_grad():
|
| 212 |
-
|
| 213 |
-
|
| 214 |
|
| 215 |
# Decode predictions
|
| 216 |
decoded_predictions = {}
|
|
@@ -241,7 +503,7 @@ def predict_analysis(
|
|
| 241 |
else:
|
| 242 |
decoded_predictions[task] = decoded.flatten()
|
| 243 |
except (IndexError, ValueError) as e:
|
| 244 |
-
|
| 245 |
# Fallback to raw indices
|
| 246 |
decoded_predictions[task] = pred_onehot.cpu().numpy()
|
| 247 |
else:
|
|
@@ -254,7 +516,7 @@ def predict_analysis(
|
|
| 254 |
else:
|
| 255 |
decoded_predictions["onset_beat"] = score.note_array()["onset_beat"]
|
| 256 |
except (AttributeError, KeyError, IndexError) as e:
|
| 257 |
-
|
| 258 |
|
| 259 |
try:
|
| 260 |
if "s_measure" in predictions:
|
|
@@ -262,7 +524,7 @@ def predict_analysis(
|
|
| 262 |
else:
|
| 263 |
decoded_predictions["measure"] = score[0].measure_number_map(score.note_array()["onset_div"])
|
| 264 |
except (AttributeError, KeyError, IndexError) as e:
|
| 265 |
-
|
| 266 |
|
| 267 |
return decoded_predictions
|
| 268 |
|
|
@@ -299,30 +561,77 @@ def process_musicxml(
|
|
| 299 |
|
| 300 |
# Load the model
|
| 301 |
status_msg = "Loading model..."
|
| 302 |
-
|
| 303 |
model = load_model()
|
| 304 |
|
| 305 |
# Load the score
|
| 306 |
status_msg = "Loading score..."
|
| 307 |
-
|
| 308 |
score = pt.load_musicxml(score_path)
|
| 309 |
|
| 310 |
parsed_score_path = save_parsed_musicxml(score, score_path)
|
| 311 |
|
| 312 |
# Render score to image
|
| 313 |
-
status_msg = "Rendering score..."
|
| 314 |
-
print(status_msg)
|
| 315 |
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp_img:
|
| 316 |
img_path = tmp_img.name
|
| 317 |
|
| 318 |
-
rendered_path =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
if rendered_path is None:
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
# Perform analysis
|
| 323 |
-
status_msg = "Running analysis..."
|
| 324 |
-
print(status_msg)
|
| 325 |
-
predictions = predict_analysis(model, score, selected_tasks)
|
| 326 |
|
| 327 |
# Create DataFrame
|
| 328 |
if predictions:
|
|
@@ -379,7 +688,7 @@ def process_musicxml(
|
|
| 379 |
|
| 380 |
except Exception as e:
|
| 381 |
error_msg = f"Error processing file: {str(e)}\n\n{traceback.format_exc()}"
|
| 382 |
-
|
| 383 |
return None, None, None, error_msg
|
| 384 |
|
| 385 |
|
|
@@ -408,6 +717,9 @@ DISPLAY_NAME_OVERRIDES = {
|
|
| 408 |
"note_degree": "Note Degree",
|
| 409 |
}
|
| 410 |
|
|
|
|
|
|
|
|
|
|
| 411 |
# Create Gradio interface
|
| 412 |
with gr.Blocks(title="AnalysisGNN Music Analysis", theme=gr.themes.Soft()) as demo:
|
| 413 |
gr.Markdown("""
|
|
@@ -457,18 +769,18 @@ with gr.Blocks(title="AnalysisGNN Music Analysis", theme=gr.themes.Soft()) as de
|
|
| 457 |
interactive=False
|
| 458 |
)
|
| 459 |
|
| 460 |
-
with gr.Row():
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
|
| 473 |
with gr.Row():
|
| 474 |
with gr.Column():
|
|
@@ -523,9 +835,9 @@ with gr.Row():
|
|
| 523 |
|
| 524 |
if not os.path.exists(example_path):
|
| 525 |
try:
|
| 526 |
-
|
| 527 |
urllib.request.urlretrieve(url, example_path)
|
| 528 |
-
|
| 529 |
except Exception as e:
|
| 530 |
return None, f"Error downloading example: {e}"
|
| 531 |
|
|
@@ -558,14 +870,11 @@ with gr.Row():
|
|
| 558 |
# Launch the app
|
| 559 |
if __name__ == "__main__":
|
| 560 |
# Pre-load the model at startup for efficiency
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
load_model()
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
print("=" * 50)
|
| 569 |
-
print("Starting Gradio interface...")
|
| 570 |
-
print("=" * 50)
|
| 571 |
demo.launch()
|
|
|
|
| 9 |
import gradio as gr
|
| 10 |
import pandas as pd
|
| 11 |
import numpy as np
|
| 12 |
+
import logging
|
| 13 |
import os
|
| 14 |
+
import shutil
|
| 15 |
+
import subprocess
|
| 16 |
import tempfile
|
| 17 |
+
import time
|
| 18 |
import torch
|
| 19 |
+
import urllib.request
|
| 20 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 21 |
+
from contextlib import contextmanager
|
| 22 |
from pathlib import Path
|
| 23 |
from typing import Tuple, Optional, Dict
|
| 24 |
import traceback
|
|
|
|
| 36 |
if "note_degree" not in available_representations and NoteDegree49 is not None:
|
| 37 |
available_representations["note_degree"] = NoteDegree49
|
| 38 |
|
| 39 |
+
LOG_LEVEL = os.environ.get("ANALYSISGNN_LOG_LEVEL", "INFO").upper()
|
| 40 |
+
logging.basicConfig(
|
| 41 |
+
level=getattr(logging, LOG_LEVEL, logging.INFO),
|
| 42 |
+
format="[%(asctime)s] %(levelname)s %(name)s: %(message)s",
|
| 43 |
+
)
|
| 44 |
+
logger = logging.getLogger("analysisgnn_app")
|
| 45 |
+
|
| 46 |
+
PARALLEL_CONFIG = os.environ.get("ANALYSISGNN_PARALLEL", "auto").strip().lower()
|
| 47 |
+
CPU_COUNT = os.cpu_count() or 1
|
| 48 |
+
|
| 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"
|
| 60 |
|
| 61 |
+
logger.info("Using device: %s", DEVICE)
|
| 62 |
if torch.cuda.is_available():
|
| 63 |
+
logger.info("CUDA device: %s", torch.cuda.get_device_name(0))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@contextmanager
|
| 67 |
+
def log_timing(label: str):
|
| 68 |
+
"""Log start/stop (with duration) for expensive operations."""
|
| 69 |
+
start = time.perf_counter()
|
| 70 |
+
logger.info("βΆ %s", label)
|
| 71 |
+
try:
|
| 72 |
+
yield
|
| 73 |
+
except Exception:
|
| 74 |
+
elapsed = time.perf_counter() - start
|
| 75 |
+
logger.exception("β %s failed after %.2fs", label, elapsed)
|
| 76 |
+
raise
|
| 77 |
+
else:
|
| 78 |
+
elapsed = time.perf_counter() - start
|
| 79 |
+
logger.info("β %s in %.2fs", label, elapsed)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def should_parallelize() -> bool:
|
| 83 |
+
"""
|
| 84 |
+
Decide whether to run analysis/visualization in parallel.
|
| 85 |
+
|
| 86 |
+
Controlled via ANALYSISGNN_PARALLEL env:
|
| 87 |
+
- "0"/"false": force sequential
|
| 88 |
+
- "1"/"true": force parallel
|
| 89 |
+
- "auto" (default): enable if more than one CPU core is available
|
| 90 |
+
"""
|
| 91 |
+
if PARALLEL_CONFIG in {"0", "false", "no", "off"}:
|
| 92 |
+
return False
|
| 93 |
+
if PARALLEL_CONFIG in {"1", "true", "yes", "on"}:
|
| 94 |
+
return True
|
| 95 |
+
return CPU_COUNT > 1
|
| 96 |
|
| 97 |
|
| 98 |
def download_wandb_checkpoint(artifact_path: str = "melkisedeath/AnalysisGNN/model-uvj2ddun:v1") -> str:
|
|
|
|
| 104 |
# Check if checkpoint already exists directly in artifacts/models
|
| 105 |
checkpoint_path = os.path.join(artifacts_dir, "model.ckpt")
|
| 106 |
if os.path.exists(checkpoint_path):
|
| 107 |
+
logger.info("Using cached checkpoint: %s", checkpoint_path)
|
| 108 |
return checkpoint_path
|
| 109 |
|
| 110 |
# Check for any .ckpt file in the artifacts/models directory
|
|
|
|
| 112 |
for fname in os.listdir(artifacts_dir):
|
| 113 |
if fname.endswith('.ckpt'):
|
| 114 |
checkpoint_path = os.path.join(artifacts_dir, fname)
|
| 115 |
+
logger.info("Using cached checkpoint: %s", checkpoint_path)
|
| 116 |
return checkpoint_path
|
| 117 |
|
| 118 |
# Check artifact-specific subdirectory
|
| 119 |
artifact_dir = os.path.join(artifacts_dir, os.path.basename(artifact_path))
|
| 120 |
checkpoint_path = os.path.join(artifact_dir, "model.ckpt")
|
| 121 |
if os.path.exists(checkpoint_path):
|
| 122 |
+
logger.info("Using cached checkpoint: %s", checkpoint_path)
|
| 123 |
return checkpoint_path
|
| 124 |
|
| 125 |
# Only import and use wandb if checkpoint is not cached
|
| 126 |
import wandb
|
| 127 |
+
logger.info("Downloading checkpoint from W&B: %s", artifact_path)
|
| 128 |
|
| 129 |
# Initialize wandb in offline mode to avoid creating online runs
|
| 130 |
run = wandb.init(mode="offline")
|
| 131 |
try:
|
| 132 |
artifact = run.use_artifact(artifact_path, type='model')
|
| 133 |
+
with log_timing("Downloading W&B checkpoint"):
|
| 134 |
+
artifact_dir = artifact.download(root=artifacts_dir)
|
| 135 |
finally:
|
| 136 |
wandb.finish()
|
| 137 |
|
|
|
|
| 152 |
|
| 153 |
if MODEL is None:
|
| 154 |
checkpoint_path = download_wandb_checkpoint()
|
| 155 |
+
logger.info("Loading model from: %s", checkpoint_path)
|
| 156 |
MODEL = ContinualAnalysisGNN.load_from_checkpoint(
|
| 157 |
checkpoint_path,
|
| 158 |
map_location=DEVICE
|
| 159 |
)
|
| 160 |
MODEL.eval()
|
| 161 |
MODEL.to(DEVICE)
|
| 162 |
+
logger.info("Model loaded successfully!")
|
| 163 |
return MODEL
|
| 164 |
|
| 165 |
|
| 166 |
+
def _format_bytes(num_bytes: float) -> str:
|
| 167 |
+
"""Return human readable size string."""
|
| 168 |
+
units = ["B", "KB", "MB", "GB", "TB"]
|
| 169 |
+
size = float(num_bytes)
|
| 170 |
+
for unit in units:
|
| 171 |
+
if size < 1024:
|
| 172 |
+
return f"{size:.1f}{unit}"
|
| 173 |
+
size /= 1024
|
| 174 |
+
return f"{size:.1f}PB"
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _download_file(url: str, destination: Path) -> bool:
|
| 178 |
+
"""Download a file from url to destination."""
|
| 179 |
+
try:
|
| 180 |
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 181 |
+
logger.info("Starting download: %s -> %s", url, destination)
|
| 182 |
+
with urllib.request.urlopen(url) as response, open(destination, "wb") as out_file:
|
| 183 |
+
total_size = int(response.headers.get("Content-Length", 0))
|
| 184 |
+
downloaded = 0
|
| 185 |
+
chunk_size = 1024 * 256
|
| 186 |
+
last_log = time.perf_counter()
|
| 187 |
+
while True:
|
| 188 |
+
chunk = response.read(chunk_size)
|
| 189 |
+
if not chunk:
|
| 190 |
+
break
|
| 191 |
+
out_file.write(chunk)
|
| 192 |
+
downloaded += len(chunk)
|
| 193 |
+
now = time.perf_counter()
|
| 194 |
+
if now - last_log > 5:
|
| 195 |
+
pct = (downloaded / total_size * 100) if total_size else 0
|
| 196 |
+
logger.info(
|
| 197 |
+
"Downloading... %s / %s (%.1f%%)",
|
| 198 |
+
_format_bytes(downloaded),
|
| 199 |
+
_format_bytes(total_size) if total_size else "unknown",
|
| 200 |
+
pct,
|
| 201 |
+
)
|
| 202 |
+
last_log = now
|
| 203 |
+
logger.info(
|
| 204 |
+
"Finished download: %s (%s)",
|
| 205 |
+
destination,
|
| 206 |
+
_format_bytes(destination.stat().st_size),
|
| 207 |
+
)
|
| 208 |
+
return True
|
| 209 |
+
except Exception as exc:
|
| 210 |
+
logger.exception("Error downloading %s: %s", url, exc)
|
| 211 |
+
return False
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _cleanup_musescore_artifacts(remove_appimage: bool = False) -> None:
|
| 215 |
+
"""Remove partially extracted MuseScore artifacts to allow a clean retry."""
|
| 216 |
+
extract_dir = MUSESCORE_STORAGE_DIR / "squashfs-root"
|
| 217 |
+
if extract_dir.exists():
|
| 218 |
+
logger.warning("Removing stale MuseScore extract at %s", extract_dir)
|
| 219 |
+
shutil.rmtree(extract_dir, ignore_errors=True)
|
| 220 |
+
if remove_appimage:
|
| 221 |
+
appimage = MUSESCORE_STORAGE_DIR / "MuseScore.AppImage"
|
| 222 |
+
if appimage.exists():
|
| 223 |
+
try:
|
| 224 |
+
appimage.unlink()
|
| 225 |
+
logger.warning("Removed corrupt MuseScore AppImage at %s", appimage)
|
| 226 |
+
except Exception:
|
| 227 |
+
logger.warning("Could not remove MuseScore AppImage at %s", appimage)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def ensure_musescore_binary() -> Optional[str]:
|
| 231 |
+
"""Ensure a MuseScore binary is available for rendering."""
|
| 232 |
+
global _MUSESCORE_BINARY
|
| 233 |
+
if _MUSESCORE_BINARY and os.path.exists(_MUSESCORE_BINARY):
|
| 234 |
+
return _MUSESCORE_BINARY
|
| 235 |
+
env_path = os.environ.get(MUSESCORE_ENV_VAR)
|
| 236 |
+
if env_path and os.path.exists(env_path):
|
| 237 |
+
logger.info("Using MuseScore binary from %s", MUSESCORE_ENV_VAR)
|
| 238 |
+
_MUSESCORE_BINARY = env_path
|
| 239 |
+
return _MUSESCORE_BINARY
|
| 240 |
+
for candidate in ("mscore", "mscore3", "musescore3", "musescore", "MuseScore3"):
|
| 241 |
+
found = shutil.which(candidate)
|
| 242 |
+
if found:
|
| 243 |
+
logger.info("Found MuseScore executable on PATH: %s", found)
|
| 244 |
+
_MUSESCORE_BINARY = found
|
| 245 |
+
return _MUSESCORE_BINARY
|
| 246 |
+
MUSESCORE_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
| 247 |
+
appimage_path = (MUSESCORE_STORAGE_DIR / "MuseScore.AppImage").resolve(strict=False)
|
| 248 |
+
apprun_path = (MUSESCORE_STORAGE_DIR / "squashfs-root" / "AppRun").resolve(strict=False)
|
| 249 |
+
if apprun_path.exists():
|
| 250 |
+
logger.info("Using cached MuseScore AppRun at %s", apprun_path)
|
| 251 |
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
| 252 |
+
_MUSESCORE_BINARY = str(apprun_path)
|
| 253 |
+
return _MUSESCORE_BINARY
|
| 254 |
+
|
| 255 |
+
for attempt in (1, 2):
|
| 256 |
+
if not appimage_path.exists() or appimage_path.stat().st_size == 0:
|
| 257 |
+
logger.info("MuseScore AppImage missing. Downloading (attempt %s)...", attempt)
|
| 258 |
+
if not _download_file(MUSESCORE_APPIMAGE_URL, appimage_path):
|
| 259 |
+
return None
|
| 260 |
+
try:
|
| 261 |
+
os.chmod(appimage_path, 0o755)
|
| 262 |
+
except Exception as exc:
|
| 263 |
+
logger.warning("Could not chmod MuseScore AppImage: %s", exc)
|
| 264 |
+
try:
|
| 265 |
+
with log_timing("Extracting MuseScore AppImage"):
|
| 266 |
+
subprocess.run(
|
| 267 |
+
[str(appimage_path), "--appimage-extract"],
|
| 268 |
+
cwd=MUSESCORE_STORAGE_DIR,
|
| 269 |
+
check=True,
|
| 270 |
+
stdout=subprocess.PIPE,
|
| 271 |
+
stderr=subprocess.PIPE,
|
| 272 |
+
timeout=MUSESCORE_EXTRACT_TIMEOUT,
|
| 273 |
+
)
|
| 274 |
+
except subprocess.CalledProcessError as exc:
|
| 275 |
+
stderr = exc.stderr.decode(errors='ignore') if exc.stderr else str(exc)
|
| 276 |
+
logger.error("MuseScore extraction failed: %s", stderr)
|
| 277 |
+
_cleanup_musescore_artifacts(remove_appimage=(attempt == 1))
|
| 278 |
+
continue
|
| 279 |
+
except subprocess.TimeoutExpired:
|
| 280 |
+
logger.error("MuseScore extraction timed out after %ss", MUSESCORE_EXTRACT_TIMEOUT)
|
| 281 |
+
_cleanup_musescore_artifacts(remove_appimage=(attempt == 1))
|
| 282 |
+
continue
|
| 283 |
+
if apprun_path.exists():
|
| 284 |
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
| 285 |
+
_MUSESCORE_BINARY = str(apprun_path)
|
| 286 |
+
try:
|
| 287 |
+
os.chmod(apprun_path, 0o755)
|
| 288 |
+
except Exception:
|
| 289 |
+
logger.debug("Could not chmod MuseScore AppRun; continuing anyway.")
|
| 290 |
+
logger.info("MuseScore AppRun ready at %s", _MUSESCORE_BINARY)
|
| 291 |
+
return _MUSESCORE_BINARY
|
| 292 |
+
logger.error("MuseScore extraction completed but AppRun was not found.")
|
| 293 |
+
_cleanup_musescore_artifacts(remove_appimage=(attempt == 1))
|
| 294 |
+
logger.error("MuseScore binary unavailable after retries.")
|
| 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 |
+
|
| 312 |
+
def _coalesce_musescore_output(output_path: str) -> Optional[str]:
|
| 313 |
+
"""
|
| 314 |
+
Normalize MuseScore CLI output when it renders multiple PNG pages.
|
| 315 |
+
|
| 316 |
+
MuseScore writes `basename-1.png`, `basename-2.png`, ... even if we request
|
| 317 |
+
a single filename. We promote the first page to the requested output path
|
| 318 |
+
so downstream code can always load one predictable image.
|
| 319 |
+
"""
|
| 320 |
+
target = Path(output_path)
|
| 321 |
+
if target.exists():
|
| 322 |
+
return str(target)
|
| 323 |
+
|
| 324 |
+
suffix = target.suffix
|
| 325 |
+
pattern = f"{target.stem}-*{suffix}" if suffix else f"{target.name}-*"
|
| 326 |
+
matches = sorted(target.parent.glob(pattern))
|
| 327 |
+
if not matches:
|
| 328 |
+
return None
|
| 329 |
+
|
| 330 |
+
first_page = matches[0]
|
| 331 |
+
normalized_path: Optional[Path] = None
|
| 332 |
+
try:
|
| 333 |
+
shutil.move(str(first_page), str(target))
|
| 334 |
+
normalized_path = target
|
| 335 |
+
except Exception:
|
| 336 |
+
try:
|
| 337 |
+
shutil.copy(str(first_page), str(target))
|
| 338 |
+
normalized_path = target
|
| 339 |
+
except Exception:
|
| 340 |
+
normalized_path = first_page
|
| 341 |
+
if normalized_path == target:
|
| 342 |
+
logger.debug("Normalized MuseScore output %s -> %s", first_page, target)
|
| 343 |
+
else:
|
| 344 |
+
logger.debug("Using MuseScore page %s as output", first_page)
|
| 345 |
+
|
| 346 |
+
# Remove leftover pages to avoid clutter, keep best-effort
|
| 347 |
+
for extra in matches:
|
| 348 |
+
if extra == first_page:
|
| 349 |
+
continue
|
| 350 |
+
try:
|
| 351 |
+
extra.unlink()
|
| 352 |
+
except Exception:
|
| 353 |
+
pass
|
| 354 |
+
|
| 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 |
+
|
| 397 |
def resolve_musicxml_path(musicxml_file) -> Optional[str]:
|
| 398 |
"""Return a filesystem path for the uploaded MusicXML file."""
|
| 399 |
if musicxml_file is None:
|
|
|
|
| 422 |
suffix = original_suffix
|
| 423 |
fd, tmp_path = tempfile.mkstemp(suffix=suffix)
|
| 424 |
os.close(fd)
|
| 425 |
+
with log_timing("Saving parsed MusicXML"):
|
| 426 |
+
pt.save_musicxml(score, tmp_path)
|
| 427 |
return tmp_path
|
| 428 |
except Exception as exc:
|
| 429 |
+
logger.warning("Could not save parsed MusicXML: %s", exc)
|
| 430 |
return None
|
| 431 |
|
| 432 |
|
| 433 |
+
def render_score_to_image(
|
| 434 |
+
score: pt.score.Score,
|
| 435 |
+
output_path: str,
|
| 436 |
+
source_musicxml_path: Optional[str] = None
|
| 437 |
+
) -> Optional[str]:
|
| 438 |
"""
|
| 439 |
+
Render score directly with the MuseScore AppRun (no other fallbacks).
|
| 440 |
|
| 441 |
+
The `score` argument is unused but kept for backward compatibility with the
|
| 442 |
+
earlier pipeline that rendered from a score object.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
"""
|
| 444 |
+
del score # Render is driven solely by the MusicXML path
|
| 445 |
+
if not source_musicxml_path or not os.path.exists(source_musicxml_path):
|
| 446 |
+
logger.error("Cannot render score: MusicXML path '%s' not found.", source_musicxml_path)
|
| 447 |
+
return None
|
| 448 |
+
return render_with_musescore(source_musicxml_path, output_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
|
| 450 |
|
| 451 |
def predict_analysis(
|
|
|
|
| 471 |
Dictionary mapping task names to predictions and confidence scores
|
| 472 |
"""
|
| 473 |
with torch.no_grad():
|
| 474 |
+
with log_timing("Model prediction"):
|
| 475 |
+
predictions = model.predict(score)
|
| 476 |
|
| 477 |
# Decode predictions
|
| 478 |
decoded_predictions = {}
|
|
|
|
| 503 |
else:
|
| 504 |
decoded_predictions[task] = decoded.flatten()
|
| 505 |
except (IndexError, ValueError) as e:
|
| 506 |
+
logger.warning("Error decoding %s predictions: %s", task, e)
|
| 507 |
# Fallback to raw indices
|
| 508 |
decoded_predictions[task] = pred_onehot.cpu().numpy()
|
| 509 |
else:
|
|
|
|
| 516 |
else:
|
| 517 |
decoded_predictions["onset_beat"] = score.note_array()["onset_beat"]
|
| 518 |
except (AttributeError, KeyError, IndexError) as e:
|
| 519 |
+
logger.warning("Could not add onset timing: %s", e)
|
| 520 |
|
| 521 |
try:
|
| 522 |
if "s_measure" in predictions:
|
|
|
|
| 524 |
else:
|
| 525 |
decoded_predictions["measure"] = score[0].measure_number_map(score.note_array()["onset_div"])
|
| 526 |
except (AttributeError, KeyError, IndexError) as e:
|
| 527 |
+
logger.warning("Could not add measure information: %s", e)
|
| 528 |
|
| 529 |
return decoded_predictions
|
| 530 |
|
|
|
|
| 561 |
|
| 562 |
# Load the model
|
| 563 |
status_msg = "Loading model..."
|
| 564 |
+
logger.info(status_msg)
|
| 565 |
model = load_model()
|
| 566 |
|
| 567 |
# Load the score
|
| 568 |
status_msg = "Loading score..."
|
| 569 |
+
logger.info(status_msg)
|
| 570 |
score = pt.load_musicxml(score_path)
|
| 571 |
|
| 572 |
parsed_score_path = save_parsed_musicxml(score, score_path)
|
| 573 |
|
| 574 |
# Render score to image
|
|
|
|
|
|
|
| 575 |
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp_img:
|
| 576 |
img_path = tmp_img.name
|
| 577 |
|
| 578 |
+
rendered_path: Optional[str] = None
|
| 579 |
+
predictions: Dict[str, np.ndarray] = {}
|
| 580 |
+
source_path = parsed_score_path or score_path
|
| 581 |
+
parallel_enabled = should_parallelize()
|
| 582 |
+
logger.info("Rendering score (parallel analysis enabled=%s)...", parallel_enabled)
|
| 583 |
+
if parallel_enabled:
|
| 584 |
+
logger.info("Running analysis and visualization in parallel (threads=%s).", 2)
|
| 585 |
+
render_success = False
|
| 586 |
+
analysis_success = False
|
| 587 |
+
with ThreadPoolExecutor(max_workers=2) as executor:
|
| 588 |
+
future_map = {
|
| 589 |
+
executor.submit(
|
| 590 |
+
render_score_to_image,
|
| 591 |
+
score,
|
| 592 |
+
img_path,
|
| 593 |
+
source_musicxml_path=source_path,
|
| 594 |
+
): "render",
|
| 595 |
+
executor.submit(
|
| 596 |
+
predict_analysis,
|
| 597 |
+
model,
|
| 598 |
+
score,
|
| 599 |
+
selected_tasks,
|
| 600 |
+
): "analysis",
|
| 601 |
+
}
|
| 602 |
+
for future in as_completed(future_map):
|
| 603 |
+
task_name = future_map[future]
|
| 604 |
+
try:
|
| 605 |
+
result = future.result()
|
| 606 |
+
except Exception:
|
| 607 |
+
logger.exception("%s task failed.", task_name.capitalize())
|
| 608 |
+
continue
|
| 609 |
+
if task_name == "render":
|
| 610 |
+
rendered_path = result
|
| 611 |
+
render_success = True
|
| 612 |
+
else:
|
| 613 |
+
predictions = result or {}
|
| 614 |
+
analysis_success = True
|
| 615 |
+
if not render_success:
|
| 616 |
+
logger.info("Retrying score rendering sequentially after parallel failure.")
|
| 617 |
+
rendered_path = render_score_to_image(
|
| 618 |
+
score,
|
| 619 |
+
img_path,
|
| 620 |
+
source_musicxml_path=source_path,
|
| 621 |
+
)
|
| 622 |
+
if not analysis_success:
|
| 623 |
+
logger.info("Retrying analysis sequentially after parallel failure.")
|
| 624 |
+
predictions = predict_analysis(model, score, selected_tasks)
|
| 625 |
+
else:
|
| 626 |
+
logger.info("Running analysis and visualization sequentially (parallel disabled).")
|
| 627 |
+
rendered_path = render_score_to_image(
|
| 628 |
+
score,
|
| 629 |
+
img_path,
|
| 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:
|
|
|
|
| 688 |
|
| 689 |
except Exception as e:
|
| 690 |
error_msg = f"Error processing file: {str(e)}\n\n{traceback.format_exc()}"
|
| 691 |
+
logger.error(error_msg)
|
| 692 |
return None, None, None, error_msg
|
| 693 |
|
| 694 |
|
|
|
|
| 717 |
"note_degree": "Note Degree",
|
| 718 |
}
|
| 719 |
|
| 720 |
+
# Ensure MuseScore AppRun is available before the UI is constructed
|
| 721 |
+
initialize_musescore_backend()
|
| 722 |
+
|
| 723 |
# Create Gradio interface
|
| 724 |
with gr.Blocks(title="AnalysisGNN Music Analysis", theme=gr.themes.Soft()) as demo:
|
| 725 |
gr.Markdown("""
|
|
|
|
| 769 |
interactive=False
|
| 770 |
)
|
| 771 |
|
| 772 |
+
with gr.Row():
|
| 773 |
+
with gr.Column():
|
| 774 |
+
# Score visualization
|
| 775 |
+
gr.Markdown("### πΌ Score Visualization")
|
| 776 |
+
image_output = gr.Image(
|
| 777 |
+
label="Rendered Score",
|
| 778 |
+
type="filepath"
|
| 779 |
+
)
|
| 780 |
+
parsed_score_output = gr.File(
|
| 781 |
+
label="Parsed MusicXML (Download)",
|
| 782 |
+
interactive=False
|
| 783 |
+
)
|
| 784 |
|
| 785 |
with gr.Row():
|
| 786 |
with gr.Column():
|
|
|
|
| 835 |
|
| 836 |
if not os.path.exists(example_path):
|
| 837 |
try:
|
| 838 |
+
logger.info("Downloading example score from: %s", url)
|
| 839 |
urllib.request.urlretrieve(url, example_path)
|
| 840 |
+
logger.info("Example score saved to: %s", example_path)
|
| 841 |
except Exception as e:
|
| 842 |
return None, f"Error downloading example: {e}"
|
| 843 |
|
|
|
|
| 870 |
# Launch the app
|
| 871 |
if __name__ == "__main__":
|
| 872 |
# Pre-load the model at startup for efficiency
|
| 873 |
+
logger.info("=" * 50)
|
| 874 |
+
logger.info("Initializing AnalysisGNN app...")
|
| 875 |
+
logger.info("=" * 50)
|
| 876 |
+
logger.info("Pre-loading model at startup...")
|
| 877 |
load_model()
|
| 878 |
+
logger.info("Model ready. Launching Gradio interface...")
|
| 879 |
+
logger.info("=" * 50)
|
|
|
|
|
|
|
|
|
|
| 880 |
demo.launch()
|