manoskary commited on
Commit
8d6029e
Β·
1 Parent(s): c74169c

Enhance README and app.py: Improve score visualization and add MuseScore AppImage handling

Browse files
Files changed (2) hide show
  1. README.md +5 -1
  2. app.py +401 -92
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
- print(f"Using device: {DEVICE}")
37
  if torch.cuda.is_available():
38
- print(f"CUDA device: {torch.cuda.get_device_name(0)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- print(f"Using cached checkpoint: {checkpoint_path}")
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
- print(f"Using cached checkpoint: {checkpoint_path}")
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
- print(f"Using cached checkpoint: {checkpoint_path}")
66
  return checkpoint_path
67
 
68
  # Only import and use wandb if checkpoint is not cached
69
  import wandb
70
- print(f"Downloading checkpoint from W&B: {artifact_path}")
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
- artifact_dir = artifact.download(root=artifacts_dir)
 
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
- print(f"Loading model from: {checkpoint_path}")
98
  MODEL = ContinualAnalysisGNN.load_from_checkpoint(
99
  checkpoint_path,
100
  map_location=DEVICE
101
  )
102
  MODEL.eval()
103
  MODEL.to(DEVICE)
104
- print("Model loaded successfully!")
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
- pt.save_musicxml(score, tmp_path)
 
137
  return tmp_path
138
  except Exception as exc:
139
- print(f"Warning: Could not save parsed MusicXML: {exc}")
140
  return None
141
 
142
 
143
- def render_score_to_image(score: pt.score.Score, output_path: str) -> Optional[str]:
 
 
 
 
144
  """
145
- Render score to image using partitura.
146
 
147
- Parameters
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
- try:
160
- # Try to render to PNG using partitura
161
- pt.render(score, fmt="png", out=output_path)
162
- if os.path.exists(output_path):
163
- return output_path
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
- # Get predictions from model
213
- predictions = model.predict(score)
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
- print(f"Warning: Error decoding {task} predictions: {e}")
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
- print(f"Warning: Could not add onset timing: {e}")
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
- print(f"Warning: Could not add measure information: {e}")
266
 
267
  return decoded_predictions
268
 
@@ -299,30 +561,77 @@ def process_musicxml(
299
 
300
  # Load the model
301
  status_msg = "Loading model..."
302
- print(status_msg)
303
  model = load_model()
304
 
305
  # Load the score
306
  status_msg = "Loading score..."
307
- print(status_msg)
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 = render_score_to_image(score, img_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  if rendered_path is None:
320
- print("Note: Score rendering failed. This requires MuseScore or LilyPond to be installed.")
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
- print(error_msg)
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
- with gr.Column():
462
- # Score visualization
463
- gr.Markdown("### 🎼 Score Visualization")
464
- image_output = gr.Image(
465
- label="Rendered Score",
466
- type="filepath"
467
- )
468
- parsed_score_output = gr.File(
469
- label="Parsed MusicXML (Download)",
470
- interactive=False
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
- print(f"Downloading example score from: {url}")
527
  urllib.request.urlretrieve(url, example_path)
528
- print(f"Example score saved to: {example_path}")
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
- print("=" * 50)
562
- print("Initializing AnalysisGNN app...")
563
- print("=" * 50)
564
- print("Pre-loading model at startup...")
565
  load_model()
566
- print("βœ“ Model loaded successfully!")
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()