k-l-lambda commited on
Commit
b48cc75
·
1 Parent(s): eb7dc71

added assets loading retry UI.

Browse files
Files changed (5) hide show
  1. app.py +4 -2
  2. lilyscript/lang.py +4 -0
  3. web/fluid-audio.js +18 -1
  4. web/score-player.css +84 -0
  5. web/score-player.js +152 -10
app.py CHANGED
@@ -436,10 +436,12 @@ def build_head ():
436
  # left under Compose + Logs (robust against Gradio's flex-nesting quirks).
437
  os.path.join(WEB_DIR, 'layout-fit.js'),
438
  ]
439
- tags = ['<script>window.__LILYSCRIPT_SOUNDFONT_URL=%r;window.__LILYSCRIPT_FLUID_URL=%r;window.__LILYSCRIPT_LOADING_RENDERER=%s;</script>'
440
  % (_file_url(os.path.join(WEB_DIR, 'soundfont')) + '/',
441
  _file_url(os.path.join(WEB_DIR, 'fluid')) + '/',
442
- json.dumps(T('loading_renderer')))]
 
 
443
  tags.append('<link rel="stylesheet" href="%s">' % _file_url(os.path.join(WEB_DIR, 'score-player.css')))
444
  tags.append('<link rel="stylesheet" href="%s">' % _file_url(os.path.join(WEB_DIR, 'lyl-editor.css')))
445
  for s in scripts:
 
436
  # left under Compose + Logs (robust against Gradio's flex-nesting quirks).
437
  os.path.join(WEB_DIR, 'layout-fit.js'),
438
  ]
439
+ tags = ['<script>window.__LILYSCRIPT_SOUNDFONT_URL=%r;window.__LILYSCRIPT_FLUID_URL=%r;window.__LILYSCRIPT_LOADING_RENDERER=%s;window.__LILYSCRIPT_EMPTY_SCORE=%s;window.__LILYSCRIPT_RENDERER_FAILED=%s;</script>'
440
  % (_file_url(os.path.join(WEB_DIR, 'soundfont')) + '/',
441
  _file_url(os.path.join(WEB_DIR, 'fluid')) + '/',
442
+ json.dumps(T('loading_renderer')),
443
+ json.dumps(T('empty_score')),
444
+ json.dumps(T('renderer_failed')))]
445
  tags.append('<link rel="stylesheet" href="%s">' % _file_url(os.path.join(WEB_DIR, 'score-player.css')))
446
  tags.append('<link rel="stylesheet" href="%s">' % _file_url(os.path.join(WEB_DIR, 'lyl-editor.css')))
447
  for s in scripts:
lilyscript/lang.py CHANGED
@@ -69,6 +69,8 @@ _STRINGS = {
69
  'open_in_live_editor': '🎹 Open in live-editor',
70
  'sheet_music': '## Sheet music',
71
  'loading_renderer': 'Loading score renderer…',
 
 
72
  },
73
  'zh': {
74
  'app_title': '## 🎼 LilyScript — 基于 Lilylet 的符号音乐生成\n\n作者 [K.L. Λ](https://k-l-lambda.github.io/), 关于 [Lilylet](https://github.com/k-l-lambda/lilylet)',
@@ -95,6 +97,8 @@ _STRINGS = {
95
  'open_in_live_editor': '🎹 在 live-editor 中打开',
96
  'sheet_music': '## 乐谱',
97
  'loading_renderer': '正在加载乐谱渲染器…',
 
 
98
  },
99
  }
100
 
 
69
  'open_in_live_editor': '🎹 Open in live-editor',
70
  'sheet_music': '## Sheet music',
71
  'loading_renderer': 'Loading score renderer…',
72
+ 'empty_score': 'Generate a score, or click one from the Score List to view and play it here.',
73
+ 'renderer_failed': 'Failed to load the score renderer. Click to retry.',
74
  },
75
  'zh': {
76
  'app_title': '## 🎼 LilyScript — 基于 Lilylet 的符号音乐生成\n\n作者 [K.L. Λ](https://k-l-lambda.github.io/), 关于 [Lilylet](https://github.com/k-l-lambda/lilylet)',
 
97
  'open_in_live_editor': '🎹 在 live-editor 中打开',
98
  'sheet_music': '## 乐谱',
99
  'loading_renderer': '正在加载乐谱渲染器…',
100
+ 'empty_score': '生成一段乐谱,或从乐谱列表中点击一个,即可在此查看并播放。',
101
+ 'renderer_failed': '乐谱渲染器加载失败,点击重试。',
102
  },
103
  }
104
 
web/fluid-audio.js CHANGED
@@ -50,6 +50,7 @@
50
  var node = null; // the synth's AudioNode (kept for diagnostics)
51
  var loaded = false;
52
  var loadingPromise = null;
 
53
 
54
  // Legacy MIDI.js fallback (single-piano), used until FluidSynth finishes loading.
55
  var legacy = null;
@@ -99,6 +100,7 @@
99
  return;
100
  }
101
 
 
102
  // 3) Fetch the 40MB gm.sf3 bytes FIRST, before touching any AudioContext.
103
  // This is the key to keeping the fallback audible the whole time: if we
104
  // instead created FluidSynth's AudioContext (+ worklet modules) up-front and
@@ -111,7 +113,9 @@
111
  // By deferring all FluidSynth context creation until the bytes are in hand,
112
  // exactly ONE AudioContext (the legacy fallback) is alive for the whole
113
  // download, so its in-gesture resume is unambiguous and it actually sounds.
114
- var sfontBuffer = await (await fetch(SOUNDFONT_FILE)).arrayBuffer();
 
 
115
  log('gm.sf3 fetched (' + sfontBuffer.byteLength + ' bytes); building FluidSynth');
116
 
117
  // Now build the FluidSynth AudioContext + worklet graph and load the font.
@@ -143,7 +147,16 @@
143
  if (legacyReady && legacy && legacy.stopAllNotes) legacy.stopAllNotes();
144
 
145
  loaded = true;
 
146
  log('FluidSynth soundfont loaded (gm.sf3)');
 
 
 
 
 
 
 
 
147
  })();
148
  return loadingPromise;
149
  }
@@ -193,6 +206,10 @@
193
  empty: empty,
194
  ready: ready,
195
  loading: loading,
 
 
 
 
196
  loadPlugin: loadPlugin,
197
  resume: resume,
198
  noteOn: noteOn,
 
50
  var node = null; // the synth's AudioNode (kept for diagnostics)
51
  var loaded = false;
52
  var loadingPromise = null;
53
+ var failed = false; // true if the last gm.sf3 load attempt errored (network etc.)
54
 
55
  // Legacy MIDI.js fallback (single-piano), used until FluidSynth finishes loading.
56
  var legacy = null;
 
100
  return;
101
  }
102
 
103
+ try {
104
  // 3) Fetch the 40MB gm.sf3 bytes FIRST, before touching any AudioContext.
105
  // This is the key to keeping the fallback audible the whole time: if we
106
  // instead created FluidSynth's AudioContext (+ worklet modules) up-front and
 
113
  // By deferring all FluidSynth context creation until the bytes are in hand,
114
  // exactly ONE AudioContext (the legacy fallback) is alive for the whole
115
  // download, so its in-gesture resume is unambiguous and it actually sounds.
116
+ var sfResp = await fetch(SOUNDFONT_FILE);
117
+ if (!sfResp.ok) throw new Error('gm.sf3 HTTP ' + sfResp.status);
118
+ var sfontBuffer = await sfResp.arrayBuffer();
119
  log('gm.sf3 fetched (' + sfontBuffer.byteLength + ' bytes); building FluidSynth');
120
 
121
  // Now build the FluidSynth AudioContext + worklet graph and load the font.
 
147
  if (legacyReady && legacy && legacy.stopAllNotes) legacy.stopAllNotes();
148
 
149
  loaded = true;
150
+ failed = false;
151
  log('FluidSynth soundfont loaded (gm.sf3)');
152
+ } catch (err) {
153
+ // gm.sf3 / FluidSynth load failed (network etc.). Flag it and clear the
154
+ // in-flight promise so retryLoad() can start a fresh attempt. The legacy
155
+ // piano fallback (if it loaded) stays audible regardless.
156
+ failed = true;
157
+ loadingPromise = null;
158
+ console.warn('[LilyFluid] gm.sf3 / FluidSynth load failed:', err);
159
+ }
160
  })();
161
  return loadingPromise;
162
  }
 
206
  empty: empty,
207
  ready: ready,
208
  loading: loading,
209
+ failed: function () { return failed; },
210
+ // Retry a previously-failed gm.sf3 load. Clears the failed flag and re-runs
211
+ // loadPlugin (loadingPromise was reset to null in the catch, so this starts fresh).
212
+ retryLoad: function () { failed = false; return loadPlugin(); },
213
  loadPlugin: loadPlugin,
214
  resume: resume,
215
  noteOn: noteOn,
web/score-player.css CHANGED
@@ -255,3 +255,87 @@
255
  animation: ls-spin 2.4s linear infinite;
256
  }
257
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  animation: ls-spin 2.4s linear infinite;
256
  }
257
  }
258
+
259
+ /* Empty-state placeholder: large staff/clef icon + prompt, shown when the renderer is
260
+ ready but no score is loaded (fresh page or cleared editor). Keeps the panel from
261
+ sitting blank. Absolutely fills the preview area so the icon + text sit dead-center
262
+ (the panel is much taller than the content, so flex-centering a normal-flow block
263
+ would only center within its own small box, leaving it near the top). */
264
+ #ls-score .ls-empty {
265
+ position: absolute;
266
+ inset: 0;
267
+ display: flex;
268
+ flex-direction: column;
269
+ align-items: center;
270
+ justify-content: center;
271
+ padding: 24px;
272
+ text-align: center;
273
+ user-select: none;
274
+ pointer-events: none; /* don't block clicks meant for the panel underneath */
275
+ }
276
+ #ls-score .ls-empty .ls-empty-icon {
277
+ width: 132px;
278
+ height: 88px;
279
+ margin-bottom: 18px;
280
+ color: #7c5cff; /* vivid accent (matches the app's purple), not a faint grey */
281
+ opacity: 0.92;
282
+ }
283
+ #ls-score .ls-empty .ls-empty-text {
284
+ font-size: 13px;
285
+ max-width: 300px;
286
+ line-height: 1.5;
287
+ color: #c4c4cc; /* lighter than the icon so the icon reads as the focal point */
288
+ }
289
+
290
+ /* Renderer-load FAILURE state (network error): shown inside the loading overlay in
291
+ place of the spinner. A clickable ⟳ retry icon + prompt; re-fetches verovio. */
292
+ #ls-score .ls-renderer-error {
293
+ display: flex;
294
+ flex-direction: column;
295
+ align-items: center;
296
+ text-align: center;
297
+ }
298
+ #ls-score .ls-retry-btn {
299
+ width: 52px;
300
+ height: 52px;
301
+ margin-bottom: 14px;
302
+ border: none;
303
+ border-radius: 50%;
304
+ background: #f0ecff;
305
+ color: #7c5cff;
306
+ cursor: pointer;
307
+ display: flex;
308
+ align-items: center;
309
+ justify-content: center;
310
+ transition: background 0.15s, transform 0.15s;
311
+ }
312
+ #ls-score .ls-retry-btn:hover { background: #e2dbff; transform: scale(1.05); }
313
+ #ls-score .ls-retry-btn:active { transform: scale(0.97); }
314
+ #ls-score .ls-retry-icon { font-size: 30px; line-height: 1; }
315
+ #ls-score .ls-renderer-error .ls-retry-text {
316
+ font-size: 13px;
317
+ max-width: 300px;
318
+ line-height: 1.5;
319
+ color: #c0392b;
320
+ }
321
+
322
+ /* Soundfont (gm.sf3) load FAILURE: the 🎹 badge becomes a clickable retry. The base
323
+ glyph is dimmed and a ⟳ overlay is shown on top; click handler calls retrySoundfont. */
324
+ #ls-score .ls-sf .ls-sf-retry {
325
+ display: none;
326
+ position: absolute;
327
+ top: 50%;
328
+ left: 50%;
329
+ transform: translate(-50%, -50%);
330
+ font-size: 0.95em;
331
+ font-weight: 700;
332
+ color: #c0392b;
333
+ }
334
+ #ls-score .ls-sf.ls-sf-err {
335
+ cursor: pointer;
336
+ animation: none;
337
+ filter: grayscale(1);
338
+ opacity: 0.85;
339
+ }
340
+ #ls-score .ls-sf.ls-sf-err .ls-sf-retry { display: inline; }
341
+ #ls-score .ls-sf.ls-sf-err .ls-sf-check { display: none; }
web/score-player.js CHANGED
@@ -24,6 +24,31 @@
24
  // window.__LILYSCRIPT_LOADING_RENDERER (matches the static placeholder's T('loading_renderer'));
25
  // fall back to English if it's absent (e.g. score-player.js loaded standalone).
26
  const LOADING_RENDERER_TEXT = window.__LILYSCRIPT_LOADING_RENDERER || 'Loading score renderer…';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  const state = {
29
  toolkit: null, // Verovio toolkit
@@ -79,8 +104,10 @@
79
  // Set the promise BEFORE the first await so concurrent callers (the mount-time
80
  // warmup + the first render) share ONE init and we never construct verovio twice.
81
  state._verovioInitPromise = (async function () {
82
- // verovio.bundle.js now loads after us; wait (up to 5 min) for its global.
83
- const VInit = await waitForGlobal('VerovioInit', 300000);
 
 
84
  if (!VInit) { log('VerovioInit global missing (timeout)'); state._verovioInitPromise = null; return null; }
85
  // VerovioInit() awaits the Emscripten WASM module's readyPromise, then
86
  // returns a constructed toolkit — the path proven by lilylet-live-editor.
@@ -168,7 +195,7 @@
168
  const tk = await initVerovio();
169
  if (!tk) { setStatus('Verovio not ready', 'err'); return false; }
170
  code = (code || '').trim();
171
- if (!code) { els.svg.innerHTML = ''; state.lastCode = ''; state.lastMei = null; updatePlayerVisibility(); return false; }
172
  if (code === state.lastCode) return true;
173
  setStatus('Rendering…', 'busy');
174
  try {
@@ -494,8 +521,18 @@
494
  els.sf.classList.remove('ready');
495
  function markReady () {
496
  els.sf.classList.add('ready');
 
497
  els.sf.title = 'Sound library ready — full instrument timbres';
498
  }
 
 
 
 
 
 
 
 
 
499
  // reflect current backend-audible state on the play button, and recover from a
500
  // build race: if a backend is now audible but the player wasn't built yet (the
501
  // first buildPlayer ran before audio was ready), build it now so play enables.
@@ -524,6 +561,7 @@
524
  var iv = setInterval(function () {
525
  syncPlayEnabled();
526
  if (F.ready()) markReady();
 
527
  if (F.ready() && state.player && state.midiData) { clearInterval(iv); state._sfInterval = null; }
528
  }, 200);
529
  state._sfInterval = iv;
@@ -531,6 +569,20 @@
531
  setTimeout(function () { if (state._sfInterval) { clearInterval(state._sfInterval); state._sfInterval = null; } }, 60000);
532
  }
533
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
  // Show the player bar only when: not generating, a score is rendered, audio
535
  // available. While generating we keep SVG visible but hide the transport.
536
  function updatePlayerVisibility () {
@@ -571,9 +623,22 @@
571
  // would sit blank with no feedback during the wait (the static placeholder is gone,
572
  // replaced by this mount). Removed once verovio is ready (setRendererLoading(false)).
573
  const loading = document.createElement('div'); loading.className = 'ls-renderer-loading';
574
- loading.innerHTML = '<div class="ls-loading-spinner" aria-hidden="true"></div>' +
575
- '<div class="ls-loading-text">' + LOADING_RENDERER_TEXT + '</div>';
576
- wrap.appendChild(status); wrap.appendChild(svgBox); wrap.appendChild(loading);
 
 
 
 
 
 
 
 
 
 
 
 
 
577
 
578
  // transport bar (above the score)
579
  const player = document.createElement('div'); player.className = 'ls-player'; player.style.display = 'none';
@@ -585,12 +650,15 @@
585
  '<div class="ls-progress"><div class="ls-fill"></div></div>' +
586
  // sound-library status: pulsing 🎹 while the GM soundfont loads (piano
587
  // fallback audible), green 🎹✓ once ready (shown 2s, then faded; hover reveals).
588
- '<span class="ls-sf" style="display:none" title="Loading sound library… (grand-piano fallback active)">🎹<span class="ls-sf-check">✓</span></span>';
 
 
589
 
590
  root.appendChild(player); root.appendChild(wrap);
591
 
592
  els.root = root; els.svg = svgBox; els.preview = wrap; els.status = status; els.player = player; els.cursor = cursor;
593
- els.loading = loading;
 
594
  els.playBtn = player.querySelector('.ls-play');
595
  els.pauseBtn = player.querySelector('.ls-pause');
596
  els.stopBtn = player.querySelector('.ls-stop');
@@ -602,6 +670,10 @@
602
  els.playBtn.addEventListener('click', play);
603
  els.pauseBtn.addEventListener('click', pause);
604
  els.stopBtn.addEventListener('click', stop);
 
 
 
 
605
  els.progress.addEventListener('click', function (e) {
606
  if (!state.midiData) return;
607
  const r = els.progress.getBoundingClientRect();
@@ -631,9 +703,13 @@
631
  });
632
 
633
  // Show the renderer-loading spinner until verovio is ready; initVerovio() resolves
634
- // when the (possibly still-downloading) verovio bundle has constructed its toolkit.
 
635
  setRendererLoading(!state.verovioReady);
636
- initVerovio().then(function (tk) { setRendererLoading(false); }); // warm up early; clears spinner when ready
 
 
 
637
  // Start loading the sound library (FluidSynth GM soundfont, ~40MB) right away
638
  // at page mount rather than deferring to the first play — the AudioContext is
639
  // created suspended (no autoplay-policy violation) and the fetch/worklet load
@@ -648,6 +724,71 @@
648
  if (!els.loading) return;
649
  var hasSvg = els.svg && els.svg.querySelector('svg');
650
  els.loading.style.display = (flag && !hasSvg) ? '' : 'none';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  }
652
 
653
  // Public API consumed by app.py's injected glue.
@@ -665,6 +806,7 @@
665
  if (els.svg) els.svg.classList.toggle('ls-generating-bg', flag);
666
  if (flag) { stop(); }
667
  updatePlayerVisibility();
 
668
  },
669
  isReady: function () { return state.verovioReady; },
670
  };
 
24
  // window.__LILYSCRIPT_LOADING_RENDERER (matches the static placeholder's T('loading_renderer'));
25
  // fall back to English if it's absent (e.g. score-player.js loaded standalone).
26
  const LOADING_RENDERER_TEXT = window.__LILYSCRIPT_LOADING_RENDERER || 'Loading score renderer…';
27
+ // Empty-state prompt shown when the renderer is ready but no score is loaded yet.
28
+ const EMPTY_SCORE_TEXT = window.__LILYSCRIPT_EMPTY_SCORE || 'Generate a score, or click one from the Score List to view and play it here.';
29
+ // Shown in the panel when verovio (the renderer) fails to load (network error). Clickable to retry.
30
+ const RENDERER_FAILED_TEXT = window.__LILYSCRIPT_RENDERER_FAILED || 'Failed to load the score renderer. Click to retry.';
31
+ // Large staff + treble-clef glyph for the empty-state placeholder. Inline SVG (no
32
+ // asset fetch); currentColor so it inherits the muted placeholder color. The five
33
+ // horizontal lines are the staff; the ♪/clef is rendered as a centered text glyph.
34
+ const STAFF_ICON_SVG =
35
+ '<svg class="ls-empty-icon" viewBox="0 0 120 80" width="120" height="80" aria-hidden="true" fill="none" stroke="currentColor">' +
36
+ '<g stroke-width="2">' +
37
+ '<line x1="8" y1="20" x2="112" y2="20"/>' +
38
+ '<line x1="8" y1="32" x2="112" y2="32"/>' +
39
+ '<line x1="8" y1="44" x2="112" y2="44"/>' +
40
+ '<line x1="8" y1="56" x2="112" y2="56"/>' +
41
+ '<line x1="8" y1="68" x2="112" y2="68"/>' +
42
+ '</g>' +
43
+ '<text x="22" y="60" font-size="56" stroke="none" fill="currentColor" font-family="serif">𝄞</text>' +
44
+ '<g fill="currentColor" stroke="none">' +
45
+ '<ellipse cx="74" cy="56" rx="7" ry="5" transform="rotate(-20 74 56)"/>' +
46
+ '<rect x="80" y="26" width="2.4" height="30"/>' +
47
+ '<ellipse cx="96" cy="44" rx="7" ry="5" transform="rotate(-20 96 44)"/>' +
48
+ '<rect x="102" y="14" width="2.4" height="30"/>' +
49
+ '<rect x="80" y="14" width="24" height="6" transform="skewX(-12)"/>' +
50
+ '</g>' +
51
+ '</svg>';
52
 
53
  const state = {
54
  toolkit: null, // Verovio toolkit
 
104
  // Set the promise BEFORE the first await so concurrent callers (the mount-time
105
  // warmup + the first render) share ONE init and we never construct verovio twice.
106
  state._verovioInitPromise = (async function () {
107
+ // verovio.bundle.js now loads after us; wait (up to 60s) for its global. On a
108
+ // network error the <script> never defines VerovioInit, so this times out → null,
109
+ // and the caller shows a retry prompt rather than spinning forever.
110
+ const VInit = await waitForGlobal('VerovioInit', 60000);
111
  if (!VInit) { log('VerovioInit global missing (timeout)'); state._verovioInitPromise = null; return null; }
112
  // VerovioInit() awaits the Emscripten WASM module's readyPromise, then
113
  // returns a constructed toolkit — the path proven by lilylet-live-editor.
 
195
  const tk = await initVerovio();
196
  if (!tk) { setStatus('Verovio not ready', 'err'); return false; }
197
  code = (code || '').trim();
198
+ if (!code) { els.svg.innerHTML = ''; state.lastCode = ''; state.lastMei = null; updatePlayerVisibility(); updateEmptyState(); return false; }
199
  if (code === state.lastCode) return true;
200
  setStatus('Rendering…', 'busy');
201
  try {
 
521
  els.sf.classList.remove('ready');
522
  function markReady () {
523
  els.sf.classList.add('ready');
524
+ els.sf.classList.remove('ls-sf-err');
525
  els.sf.title = 'Sound library ready — full instrument timbres';
526
  }
527
+ // gm.sf3 failed to load: turn the 🎹 badge into a clickable retry (⟳ overlay).
528
+ // The legacy piano fallback (if loaded) stays audible, so play still works — this
529
+ // only signals the full GM timbres are unavailable until retried.
530
+ function markFailed () {
531
+ els.sf.classList.remove('ready');
532
+ els.sf.classList.add('ls-sf-err');
533
+ els.sf.style.cursor = 'pointer';
534
+ els.sf.title = 'Sound library failed to load — click to retry';
535
+ }
536
  // reflect current backend-audible state on the play button, and recover from a
537
  // build race: if a backend is now audible but the player wasn't built yet (the
538
  // first buildPlayer ran before audio was ready), build it now so play enables.
 
561
  var iv = setInterval(function () {
562
  syncPlayEnabled();
563
  if (F.ready()) markReady();
564
+ else if (F.failed && F.failed()) markFailed();
565
  if (F.ready() && state.player && state.midiData) { clearInterval(iv); state._sfInterval = null; }
566
  }, 200);
567
  state._sfInterval = iv;
 
569
  setTimeout(function () { if (state._sfInterval) { clearInterval(state._sfInterval); state._sfInterval = null; } }, 60000);
570
  }
571
 
572
+ // Retry a failed gm.sf3 / soundfont load (driven by clicking the 🎹 badge in its
573
+ // error state). Clears the badge error, kicks a fresh load via the backend's
574
+ // retryLoad(), and restarts the status poller so the badge tracks the new attempt.
575
+ function retrySoundfont () {
576
+ var F = state.audio && state.audio.MidiAudio;
577
+ if (!F || !F.retryLoad) return;
578
+ els.sf.classList.remove('ls-sf-err');
579
+ els.sf.title = 'Loading sound library…';
580
+ // stop the current poller so startSfStatus re-arms cleanly
581
+ if (state._sfInterval) { clearInterval(state._sfInterval); state._sfInterval = null; }
582
+ F.retryLoad();
583
+ startSfStatus();
584
+ }
585
+
586
  // Show the player bar only when: not generating, a score is rendered, audio
587
  // available. While generating we keep SVG visible but hide the transport.
588
  function updatePlayerVisibility () {
 
623
  // would sit blank with no feedback during the wait (the static placeholder is gone,
624
  // replaced by this mount). Removed once verovio is ready (setRendererLoading(false)).
625
  const loading = document.createElement('div'); loading.className = 'ls-renderer-loading';
626
+ loading.innerHTML =
627
+ '<div class="ls-loading-spinner" aria-hidden="true"></div>' +
628
+ '<div class="ls-loading-text">' + LOADING_RENDERER_TEXT + '</div>' +
629
+ // error/retry block (hidden until verovio fails to load). The ⟳ icon + text are
630
+ // clickable to retry; see setRendererError() / retryVerovio().
631
+ '<div class="ls-renderer-error" style="display:none">' +
632
+ '<button type="button" class="ls-retry-btn" title="Retry"><span class="ls-retry-icon" aria-hidden="true">⟳</span></button>' +
633
+ '<div class="ls-retry-text">' + RENDERER_FAILED_TEXT + '</div>' +
634
+ '</div>';
635
+ // Empty-state placeholder: a large staff/treble-clef icon + prompt, shown when the
636
+ // renderer is ready but no score is loaded (fresh page, or editor cleared). Without
637
+ // it the panel would be blank once the loading spinner clears. Hidden when a score
638
+ // renders or while generating. Icon is inline SVG so it needs no extra asset fetch.
639
+ const empty = document.createElement('div'); empty.className = 'ls-empty'; empty.style.display = 'none';
640
+ empty.innerHTML = STAFF_ICON_SVG + '<div class="ls-empty-text">' + EMPTY_SCORE_TEXT + '</div>';
641
+ wrap.appendChild(status); wrap.appendChild(svgBox); wrap.appendChild(loading); wrap.appendChild(empty);
642
 
643
  // transport bar (above the score)
644
  const player = document.createElement('div'); player.className = 'ls-player'; player.style.display = 'none';
 
650
  '<div class="ls-progress"><div class="ls-fill"></div></div>' +
651
  // sound-library status: pulsing 🎹 while the GM soundfont loads (piano
652
  // fallback audible), green 🎹✓ once ready (shown 2s, then faded; hover reveals).
653
+ // On load failure it becomes a clickable retry (⟳ overlay, .ls-sf-err); see
654
+ // startSfStatus() / retrySoundfont().
655
+ '<span class="ls-sf" style="display:none" title="Loading sound library… (grand-piano fallback active)">🎹<span class="ls-sf-check">✓</span><span class="ls-sf-retry" aria-hidden="true">⟳</span></span>';
656
 
657
  root.appendChild(player); root.appendChild(wrap);
658
 
659
  els.root = root; els.svg = svgBox; els.preview = wrap; els.status = status; els.player = player; els.cursor = cursor;
660
+ els.loading = loading; els.empty = empty;
661
+ els.rendererError = loading.querySelector('.ls-renderer-error');
662
  els.playBtn = player.querySelector('.ls-play');
663
  els.pauseBtn = player.querySelector('.ls-pause');
664
  els.stopBtn = player.querySelector('.ls-stop');
 
670
  els.playBtn.addEventListener('click', play);
671
  els.pauseBtn.addEventListener('click', pause);
672
  els.stopBtn.addEventListener('click', stop);
673
+ // retry verovio (renderer) on click of the in-panel error prompt
674
+ els.rendererError.querySelector('.ls-retry-btn').addEventListener('click', retryVerovio);
675
+ // retry the soundfont when the 🎹 badge is in its error state (clickable then)
676
+ els.sf.addEventListener('click', function () { if (els.sf.classList.contains('ls-sf-err')) retrySoundfont(); });
677
  els.progress.addEventListener('click', function (e) {
678
  if (!state.midiData) return;
679
  const r = els.progress.getBoundingClientRect();
 
703
  });
704
 
705
  // Show the renderer-loading spinner until verovio is ready; initVerovio() resolves
706
+ // when the (possibly still-downloading) verovio bundle has constructed its toolkit,
707
+ // or null on failure/timeout (network error) — in which case show a retry prompt.
708
  setRendererLoading(!state.verovioReady);
709
+ initVerovio().then(function (tk) {
710
+ if (tk) setRendererLoading(false);
711
+ else setRendererError(); // network/load failure → offer a retry (not a blank/empty state)
712
+ });
713
  // Start loading the sound library (FluidSynth GM soundfont, ~40MB) right away
714
  // at page mount rather than deferring to the first play — the AudioContext is
715
  // created suspended (no autoplay-policy violation) and the fetch/worklet load
 
724
  if (!els.loading) return;
725
  var hasSvg = els.svg && els.svg.querySelector('svg');
726
  els.loading.style.display = (flag && !hasSvg) ? '' : 'none';
727
+ // leaving the loading state (success) clears any prior error UI
728
+ if (!flag) showRendererErrorBlock(false);
729
+ updateEmptyState();
730
+ }
731
+
732
+ // Toggle just the spinner-vs-error contents inside the loading overlay.
733
+ function showRendererErrorBlock (isError) {
734
+ if (els.rendererError) els.rendererError.style.display = isError ? '' : 'none';
735
+ var spinner = els.loading && els.loading.querySelector('.ls-loading-spinner');
736
+ var ltext = els.loading && els.loading.querySelector('.ls-loading-text');
737
+ if (spinner) spinner.style.display = isError ? 'none' : '';
738
+ if (ltext) ltext.style.display = isError ? 'none' : '';
739
+ }
740
+
741
+ // Renderer (verovio) failed to load — keep the overlay up but swap the spinner for a
742
+ // clickable retry prompt. state.verovioReady stays false, so the empty-state stays
743
+ // suppressed (we must not show "click a score to play" when the renderer is dead).
744
+ function setRendererError () {
745
+ state._rendererError = true;
746
+ if (!els.loading) return;
747
+ els.loading.style.display = ''; // keep overlay visible
748
+ showRendererErrorBlock(true);
749
+ updateEmptyState();
750
+ }
751
+
752
+ // Retry loading verovio: re-fetch the bundle <script> (a failed network load left no
753
+ // VerovioInit), reset the cached init promise, and re-run initVerovio. Driven by the
754
+ // retry button in the renderer-error block.
755
+ function retryVerovio () {
756
+ if (state.verovioReady) { setRendererLoading(false); return; }
757
+ state._rendererError = false;
758
+ state._verovioInitPromise = null;
759
+ showRendererErrorBlock(false); // back to spinner while retrying
760
+ setRendererLoading(true);
761
+ reloadVendorScript('verovio.bundle.js');
762
+ initVerovio().then(function (tk) {
763
+ if (tk) { setRendererLoading(false); if (state.lastCode) render(state.lastCode); }
764
+ else setRendererError();
765
+ });
766
+ }
767
+
768
+ // Re-inject a vendored <script> by URL fragment (e.g. 'verovio.bundle.js') so a retry
769
+ // actually re-downloads it. Reuses the original tag's src; appends a cache-buster so a
770
+ // previously-failed (and possibly negatively-cached) response isn't served again.
771
+ function reloadVendorScript (frag) {
772
+ var orig = null, tags = document.querySelectorAll('script[src]');
773
+ for (var i = 0; i < tags.length; i++) { if (tags[i].src.indexOf(frag) !== -1) { orig = tags[i]; break; } }
774
+ var base = orig ? orig.src.split('?')[0] : null;
775
+ if (!base) { log('retry: original <script> for ' + frag + ' not found'); return; }
776
+ var s = document.createElement('script');
777
+ s.src = base + '?retry=' + Date.now();
778
+ s.async = false;
779
+ document.head.appendChild(s);
780
+ }
781
+
782
+ // Show the empty-state placeholder (staff icon + prompt) exactly when there is
783
+ // nothing else to show: renderer ready, no score rendered, not generating, and the
784
+ // loading spinner isn't up. Keeps the panel from sitting blank before the user picks
785
+ // or generates a score. Called from every state transition that can change it.
786
+ function updateEmptyState () {
787
+ if (!els.empty) return;
788
+ var hasSvg = els.svg && els.svg.querySelector('svg');
789
+ var loadingShown = els.loading && els.loading.style.display !== 'none';
790
+ var show = state.verovioReady && !hasSvg && !loadingShown && !state.generating && !state._rendererError;
791
+ els.empty.style.display = show ? '' : 'none';
792
  }
793
 
794
  // Public API consumed by app.py's injected glue.
 
806
  if (els.svg) els.svg.classList.toggle('ls-generating-bg', flag);
807
  if (flag) { stop(); }
808
  updatePlayerVisibility();
809
+ updateEmptyState();
810
  },
811
  isReady: function () { return state.verovioReady; },
812
  };