Spaces:
Running
Running
Commit ·
b48cc75
1
Parent(s): eb7dc71
added assets loading retry UI.
Browse files- app.py +4 -2
- lilyscript/lang.py +4 -0
- web/fluid-audio.js +18 -1
- web/score-player.css +84 -0
- 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
|
|
|
|
|
|
|
| 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
|
| 83 |
-
|
|
|
|
|
|
|
| 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 =
|
| 575 |
-
'<div class="ls-loading-
|
| 576 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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) {
|
|
|
|
|
|
|
|
|
|
| 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 |
};
|