Spaces:
Running
Running
Commit ·
3225df7
1
Parent(s): 94fe5c1
added URL hash for loaded score file.
Browse files- app.py +55 -13
- assets/styles.json +1 -1
- web/fluid-audio.js +27 -9
- web/layout-fit.js +46 -20
- web/score-player.css +9 -0
- web/score-player.js +90 -22
app.py
CHANGED
|
@@ -380,6 +380,47 @@ function () {
|
|
| 380 |
const iv = setInterval(() => { if (tryMount()) clearInterval(iv); }, 200);
|
| 381 |
setTimeout(() => clearInterval(iv), 20000);
|
| 382 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
}
|
| 384 |
'''
|
| 385 |
|
|
@@ -471,20 +512,19 @@ CUSTOM_CSS = '''
|
|
| 471 |
Goal: Compose + Logs keep their natural height; the Score List | editor row fills
|
| 472 |
the rest; a long score must NOT stretch the page (it scrolls inside the sheet
|
| 473 |
panel instead). Gradio's deep wrapper nesting makes pure-CSS flex height
|
| 474 |
-
propagation unreliable, so the panel heights are driven
|
| 475 |
-
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
| 477 |
#main-row {
|
| 478 |
align-items: flex-start; /* don't stretch columns to each other's height */
|
| 479 |
}
|
| 480 |
-
/* Right (Sheet music) column: the score preview scrolls inside a
|
| 481 |
-
|
| 482 |
-
#sheet-col {
|
| 483 |
-
position: sticky;
|
| 484 |
-
top: 8px;
|
| 485 |
-
}
|
| 486 |
#sheet-col .ls-score-root {
|
| 487 |
-
height:
|
| 488 |
min-height: 320px;
|
| 489 |
}
|
| 490 |
#sheet-col .ls-preview {
|
|
@@ -496,10 +536,10 @@ CUSTOM_CSS = '''
|
|
| 496 |
display: flex;
|
| 497 |
flex-direction: column;
|
| 498 |
}
|
| 499 |
-
/* The bottom Score List | editor row gets
|
| 500 |
-
|
| 501 |
#compose-col > .lp-fill {
|
| 502 |
-
height: var(--ls-fill-h,
|
| 503 |
min-height: 360px;
|
| 504 |
}
|
| 505 |
#compose-col > .lp-fill > .column,
|
|
@@ -676,6 +716,8 @@ def build_ui ():
|
|
| 676 |
lambda: gr.update(value='Generate'), None, outputs=[gen_btn], cancels=[gen_event],
|
| 677 |
).then(None, None, None, js=_JS_GEN_END)
|
| 678 |
file_list.select(load_file, inputs=[file_list, store], outputs=[editor])
|
|
|
|
|
|
|
| 679 |
|
| 680 |
return demo
|
| 681 |
|
|
|
|
| 380 |
const iv = setInterval(() => { if (tryMount()) clearInterval(iv); }, 200);
|
| 381 |
setTimeout(() => clearInterval(iv), 20000);
|
| 382 |
}
|
| 383 |
+
|
| 384 |
+
// Deep-link: if the URL carries #score=<file>, select that Score List entry on
|
| 385 |
+
// first load (so a bookmarked/shared score opens directly). We strip the leading
|
| 386 |
+
// emoji prefix (📄/✨) when comparing, and click the matching radio input — that
|
| 387 |
+
// fires Gradio's .select handler, which loads the file into the editor exactly
|
| 388 |
+
// as a manual click would. Poll until the radio list is populated.
|
| 389 |
+
const stripPrefix = (s) => (s || '').replace(/^\\s*[\\u{1F4C4}\\u2728]\\s*/u, '').trim();
|
| 390 |
+
const m = (location.hash || '').match(/(?:^#|&)score=([^&]*)/);
|
| 391 |
+
if (m) {
|
| 392 |
+
const want = decodeURIComponent(m[1]);
|
| 393 |
+
let tries = 0;
|
| 394 |
+
const pick = () => {
|
| 395 |
+
const labels = document.querySelectorAll('.score-list label');
|
| 396 |
+
if (!labels.length) { if (++tries < 100) setTimeout(pick, 150); return; }
|
| 397 |
+
for (const lab of labels) {
|
| 398 |
+
const span = lab.querySelector('span');
|
| 399 |
+
const text = stripPrefix(span ? span.textContent : lab.textContent);
|
| 400 |
+
if (text === want) {
|
| 401 |
+
const input = lab.querySelector('input');
|
| 402 |
+
if (input && !input.checked) input.click();
|
| 403 |
+
return;
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
if (++tries < 40) setTimeout(pick, 150); // list may still be filling
|
| 407 |
+
};
|
| 408 |
+
pick();
|
| 409 |
+
}
|
| 410 |
+
}
|
| 411 |
+
'''
|
| 412 |
+
|
| 413 |
+
# file-list selection -> write the chosen file into location.hash (#score=<file>) so
|
| 414 |
+
# the URL deep-links to it. This is a js-ONLY listener (no python fn) running beside
|
| 415 |
+
# the load_file handler; `value` is the selected radio label. Strip the emoji prefix
|
| 416 |
+
# for a clean, shareable hash. Returning [] is fine — there are no outputs.
|
| 417 |
+
_JS_SELECT_HASH = '''
|
| 418 |
+
function (value) {
|
| 419 |
+
try {
|
| 420 |
+
const name = (value || '').replace(/^\\s*[\\u{1F4C4}\\u2728]\\s*/u, '').trim();
|
| 421 |
+
if (name) history.replaceState(null, '', '#score=' + encodeURIComponent(name));
|
| 422 |
+
} catch (e) {}
|
| 423 |
+
return [];
|
| 424 |
}
|
| 425 |
'''
|
| 426 |
|
|
|
|
| 512 |
Goal: Compose + Logs keep their natural height; the Score List | editor row fills
|
| 513 |
the rest; a long score must NOT stretch the page (it scrolls inside the sheet
|
| 514 |
panel instead). Gradio's deep wrapper nesting makes pure-CSS flex height
|
| 515 |
+
propagation unreliable, so the panel heights are driven from JS in
|
| 516 |
+
web/layout-fit.js, which sets --ls-fill-h (bottom row) and --ls-sheet-h (sheet
|
| 517 |
+
panel) on :root. IMPORTANT: those are plain pixel values, NOT 100vh — when this
|
| 518 |
+
app is embedded in an auto-height iframe (Hugging Face Spaces), any 100vh/viewport
|
| 519 |
+
reference feeds back into the iframe's growing height and the page scroll height
|
| 520 |
+
runs away forever. layout-fit.js detects the iframe and uses fixed heights there. */
|
| 521 |
#main-row {
|
| 522 |
align-items: flex-start; /* don't stretch columns to each other's height */
|
| 523 |
}
|
| 524 |
+
/* Right (Sheet music) column: the score preview scrolls inside a bounded height
|
| 525 |
+
(set by layout-fit.js) instead of growing the row / page. */
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
#sheet-col .ls-score-root {
|
| 527 |
+
height: var(--ls-sheet-h, 720px);
|
| 528 |
min-height: 320px;
|
| 529 |
}
|
| 530 |
#sheet-col .ls-preview {
|
|
|
|
| 536 |
display: flex;
|
| 537 |
flex-direction: column;
|
| 538 |
}
|
| 539 |
+
/* The bottom Score List | editor row gets a bounded height from layout-fit.js
|
| 540 |
+
(--ls-fill-h); never a viewport unit (see the iframe note above). */
|
| 541 |
#compose-col > .lp-fill {
|
| 542 |
+
height: var(--ls-fill-h, 460px);
|
| 543 |
min-height: 360px;
|
| 544 |
}
|
| 545 |
#compose-col > .lp-fill > .column,
|
|
|
|
| 716 |
lambda: gr.update(value='Generate'), None, outputs=[gen_btn], cancels=[gen_event],
|
| 717 |
).then(None, None, None, js=_JS_GEN_END)
|
| 718 |
file_list.select(load_file, inputs=[file_list, store], outputs=[editor])
|
| 719 |
+
# separate js-only listener: mirror the selected file into location.hash for deep-linking
|
| 720 |
+
file_list.select(None, inputs=[file_list], outputs=None, js=_JS_SELECT_HASH)
|
| 721 |
|
| 722 |
return demo
|
| 723 |
|
assets/styles.json
CHANGED
|
@@ -19,5 +19,5 @@
|
|
| 19 |
"Shostakovich, Dmitry"
|
| 20 |
],
|
| 21 |
"periods": ["Baroque", "Classical", "Romantic", "Modern"],
|
| 22 |
-
"genres": ["Keyboard", "
|
| 23 |
}
|
|
|
|
| 19 |
"Shostakovich, Dmitry"
|
| 20 |
],
|
| 21 |
"periods": ["Baroque", "Classical", "Romantic", "Modern"],
|
| 22 |
+
"genres": ["Keyboard", "Art Song", "Chamber", "Orchestral"]
|
| 23 |
}
|
web/fluid-audio.js
CHANGED
|
@@ -69,17 +69,24 @@
|
|
| 69 |
if (loaded) return Promise.resolve();
|
| 70 |
if (loadingPromise) return loadingPromise;
|
| 71 |
|
| 72 |
-
//
|
| 73 |
-
//
|
|
|
|
|
|
|
|
|
|
| 74 |
if (legacy && legacy.WebAudio && legacy.WebAudio.empty && legacy.WebAudio.empty()) {
|
| 75 |
-
legacy.loadPlugin({ soundfontUrl: SOUNDFONT_URL, api: 'webaudio' })
|
| 76 |
.then(function () { legacyReady = true; log('legacy piano fallback ready'); })
|
| 77 |
.catch(function (err) { console.warn('[LilyFluid] legacy fallback failed:', err); });
|
| 78 |
-
} else
|
| 79 |
-
legacyReady = true;
|
|
|
|
| 80 |
}
|
| 81 |
|
| 82 |
loadingPromise = (async function () {
|
|
|
|
|
|
|
|
|
|
| 83 |
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
| 84 |
// libfluidsynth must be added before the worklet module.
|
| 85 |
await audioCtx.audioWorklet.addModule(LIBFLUIDSYNTH_FILE);
|
|
@@ -108,12 +115,23 @@
|
|
| 108 |
}
|
| 109 |
|
| 110 |
// Resume audio output; must be called from a user gesture (autoplay policy).
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
var WA = legacy && legacy.WebAudio;
|
| 114 |
-
if (legacyReady && WA && WA.
|
| 115 |
-
try {
|
| 116 |
}
|
|
|
|
| 117 |
}
|
| 118 |
|
| 119 |
function noteOn (channel, note, velocity, timestamp) {
|
|
|
|
| 69 |
if (loaded) return Promise.resolve();
|
| 70 |
if (loadingPromise) return loadingPromise;
|
| 71 |
|
| 72 |
+
// 1) Load the lightweight legacy piano fallback FIRST and prioritise it, so
|
| 73 |
+
// playback is audible as soon as possible. The large GM soundfont (gm.sf3,
|
| 74 |
+
// ~40MB) only starts downloading once the fallback is ready — we don't want
|
| 75 |
+
// the 40MB fetch competing for bandwidth with the small fallback font.
|
| 76 |
+
var legacyPromise;
|
| 77 |
if (legacy && legacy.WebAudio && legacy.WebAudio.empty && legacy.WebAudio.empty()) {
|
| 78 |
+
legacyPromise = legacy.loadPlugin({ soundfontUrl: SOUNDFONT_URL, api: 'webaudio' })
|
| 79 |
.then(function () { legacyReady = true; log('legacy piano fallback ready'); })
|
| 80 |
.catch(function (err) { console.warn('[LilyFluid] legacy fallback failed:', err); });
|
| 81 |
+
} else {
|
| 82 |
+
if (legacy) legacyReady = true;
|
| 83 |
+
legacyPromise = Promise.resolve();
|
| 84 |
}
|
| 85 |
|
| 86 |
loadingPromise = (async function () {
|
| 87 |
+
// 2) Wait for the fallback to finish before kicking off the heavy GM load.
|
| 88 |
+
try { await legacyPromise; } catch (e) {}
|
| 89 |
+
|
| 90 |
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
| 91 |
// libfluidsynth must be added before the worklet module.
|
| 92 |
await audioCtx.audioWorklet.addModule(LIBFLUIDSYNTH_FILE);
|
|
|
|
| 115 |
}
|
| 116 |
|
| 117 |
// Resume audio output; must be called from a user gesture (autoplay policy).
|
| 118 |
+
// CRITICAL: both the FluidSynth AudioContext AND the legacy fallback's WebAudio
|
| 119 |
+
// context start suspended and can only be resumed *synchronously within the user
|
| 120 |
+
// gesture*. So we must fire BOTH resume()/awaitWarmup() calls BEFORE the first
|
| 121 |
+
// `await` — otherwise the second one runs in a later microtask, outside the gesture
|
| 122 |
+
// activation, and the browser refuses to resume it (it hangs/rejects). That was the
|
| 123 |
+
// "fallback plays but is silent" bug: only FluidSynth's context got resumed, the
|
| 124 |
+
// legacy one stayed suspended so its buffer sources produced no sound.
|
| 125 |
+
function resume () {
|
| 126 |
+
var promises = [];
|
| 127 |
+
if (audioCtx && audioCtx.state === 'suspended') {
|
| 128 |
+
try { promises.push(audioCtx.resume()); } catch (e) {}
|
| 129 |
+
}
|
| 130 |
var WA = legacy && legacy.WebAudio;
|
| 131 |
+
if (legacyReady && WA && WA.awaitWarmup) {
|
| 132 |
+
try { promises.push(WA.awaitWarmup()); } catch (e) {}
|
| 133 |
}
|
| 134 |
+
return Promise.all(promises).catch(function () {});
|
| 135 |
}
|
| 136 |
|
| 137 |
function noteOn (channel, note, velocity, timestamp) {
|
web/layout-fit.js
CHANGED
|
@@ -1,35 +1,55 @@
|
|
| 1 |
/* LilyScript layout fitter.
|
| 2 |
*
|
| 3 |
-
*
|
| 4 |
-
*
|
| 5 |
-
*
|
| 6 |
-
* editor" row in JS and expose it as the CSS variable --ls-fill-h on :root.
|
| 7 |
*
|
| 8 |
-
*
|
| 9 |
*
|
| 10 |
-
*
|
| 11 |
-
*
|
| 12 |
-
*
|
| 13 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
*/
|
| 15 |
(function () {
|
| 16 |
'use strict';
|
| 17 |
|
| 18 |
-
var BOTTOM_GAP = 16;
|
| 19 |
-
var MIN_FILL = 360;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
function fillEl () {
|
| 22 |
var col = document.getElementById('compose-col');
|
| 23 |
return col ? col.querySelector(':scope > .lp-fill') : null;
|
| 24 |
}
|
| 25 |
|
|
|
|
| 26 |
function recompute () {
|
| 27 |
var fill = fillEl();
|
| 28 |
if (!fill) return;
|
| 29 |
-
var top = fill.getBoundingClientRect().top;
|
| 30 |
var avail = window.innerHeight - top - BOTTOM_GAP;
|
| 31 |
if (avail < MIN_FILL) avail = MIN_FILL;
|
| 32 |
-
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
var raf = null;
|
|
@@ -39,18 +59,25 @@
|
|
| 39 |
}
|
| 40 |
|
| 41 |
function boot () {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
recompute();
|
| 43 |
window.addEventListener('resize', schedule);
|
| 44 |
-
// observe the left column so Logs growth / accordion toggles re-fit
|
| 45 |
-
var col = document.getElementById('compose-col');
|
| 46 |
if (col && window.ResizeObserver) {
|
| 47 |
var ro = new ResizeObserver(schedule);
|
| 48 |
ro.observe(col);
|
| 49 |
-
//
|
| 50 |
var fixed = col.querySelectorAll(':scope > .lp-fixed');
|
| 51 |
for (var i = 0; i < fixed.length; i++) ro.observe(fixed[i]);
|
| 52 |
}
|
| 53 |
-
// Gradio mounts asynchronously; retry a few times until #compose-col exists.
|
| 54 |
if (!col) setTimeout(boot, 300);
|
| 55 |
}
|
| 56 |
|
|
@@ -59,7 +86,6 @@
|
|
| 59 |
} else {
|
| 60 |
boot();
|
| 61 |
}
|
| 62 |
-
//
|
| 63 |
-
|
| 64 |
-
console.log('[layout-fit] loaded');
|
| 65 |
})();
|
|
|
|
| 1 |
/* LilyScript layout fitter.
|
| 2 |
*
|
| 3 |
+
* Sets two CSS variables on :root that drive the workspace panel heights:
|
| 4 |
+
* --ls-fill-h height of the bottom "Score List | editor" row (left column)
|
| 5 |
+
* --ls-sheet-h height of the right "Sheet music" preview panel
|
|
|
|
| 6 |
*
|
| 7 |
+
* Two modes, because the embedding context decides what "fill the screen" means:
|
| 8 |
*
|
| 9 |
+
* • Standalone (the app is the top-level page): size to the real viewport, so the
|
| 10 |
+
* bottom row fills the space under Compose + Logs and the sheet panel fills the
|
| 11 |
+
* window. Recompute on resize + when the left column changes (ResizeObserver).
|
| 12 |
+
*
|
| 13 |
+
* • Embedded in an auto-height iframe (Hugging Face Spaces wraps the Gradio app in
|
| 14 |
+
* an iframe that grows to fit its content): here window.innerHeight / 100vh are
|
| 15 |
+
* NOT stable — they track the iframe, which tracks the content, which we'd be
|
| 16 |
+
* sizing from the viewport… an unbounded feedback loop (the page scroll height
|
| 17 |
+
* grows forever). So when embedded we use FIXED pixel heights and install no
|
| 18 |
+
* viewport/resize observers. Nothing references the viewport, so nothing loops.
|
| 19 |
*/
|
| 20 |
(function () {
|
| 21 |
'use strict';
|
| 22 |
|
| 23 |
+
var BOTTOM_GAP = 16; // breathing room below the fill row (standalone)
|
| 24 |
+
var MIN_FILL = 360; // floor for the bottom row
|
| 25 |
+
// Fixed heights used when embedded (no viewport to measure against).
|
| 26 |
+
var EMBED_FILL_H = 460; // Score List | editor row
|
| 27 |
+
var EMBED_SHEET_H = 720; // Sheet music preview
|
| 28 |
+
|
| 29 |
+
// True when running inside an iframe (HF Spaces, blog embeds, etc.). Accessing
|
| 30 |
+
// window.top across origins throws — treat that as "embedded" too.
|
| 31 |
+
var embedded = (function () {
|
| 32 |
+
try { return window.self !== window.top; } catch (e) { return true; }
|
| 33 |
+
})();
|
| 34 |
+
|
| 35 |
+
function setVar (name, px) {
|
| 36 |
+
document.documentElement.style.setProperty(name, px + 'px');
|
| 37 |
+
}
|
| 38 |
|
| 39 |
function fillEl () {
|
| 40 |
var col = document.getElementById('compose-col');
|
| 41 |
return col ? col.querySelector(':scope > .lp-fill') : null;
|
| 42 |
}
|
| 43 |
|
| 44 |
+
// Standalone: derive the bottom row height from the live viewport.
|
| 45 |
function recompute () {
|
| 46 |
var fill = fillEl();
|
| 47 |
if (!fill) return;
|
| 48 |
+
var top = fill.getBoundingClientRect().top; // viewport-relative
|
| 49 |
var avail = window.innerHeight - top - BOTTOM_GAP;
|
| 50 |
if (avail < MIN_FILL) avail = MIN_FILL;
|
| 51 |
+
setVar('--ls-fill-h', avail);
|
| 52 |
+
setVar('--ls-sheet-h', Math.max(320, window.innerHeight - 110));
|
| 53 |
}
|
| 54 |
|
| 55 |
var raf = null;
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
function boot () {
|
| 62 |
+
var col = document.getElementById('compose-col');
|
| 63 |
+
|
| 64 |
+
if (embedded) {
|
| 65 |
+
// Fixed heights — no viewport reads, no observers, no loop.
|
| 66 |
+
setVar('--ls-fill-h', EMBED_FILL_H);
|
| 67 |
+
setVar('--ls-sheet-h', EMBED_SHEET_H);
|
| 68 |
+
if (!col) setTimeout(boot, 300); // wait for Gradio to mount, then set once
|
| 69 |
+
return;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
recompute();
|
| 73 |
window.addEventListener('resize', schedule);
|
|
|
|
|
|
|
| 74 |
if (col && window.ResizeObserver) {
|
| 75 |
var ro = new ResizeObserver(schedule);
|
| 76 |
ro.observe(col);
|
| 77 |
+
// the two fixed blocks (Compose, Logs) drive the fill row's top
|
| 78 |
var fixed = col.querySelectorAll(':scope > .lp-fixed');
|
| 79 |
for (var i = 0; i < fixed.length; i++) ro.observe(fixed[i]);
|
| 80 |
}
|
|
|
|
| 81 |
if (!col) setTimeout(boot, 300);
|
| 82 |
}
|
| 83 |
|
|
|
|
| 86 |
} else {
|
| 87 |
boot();
|
| 88 |
}
|
| 89 |
+
if (!embedded) setTimeout(recompute, 1200); // late pass after fonts/player settle
|
| 90 |
+
console.log('[layout-fit] loaded (embedded=' + embedded + ')');
|
|
|
|
| 91 |
})();
|
web/score-player.css
CHANGED
|
@@ -61,6 +61,15 @@
|
|
| 61 |
max-width: 100%;
|
| 62 |
height: auto;
|
| 63 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
/* Playback cursor: a vertical line tracking the currently-sounding note. */
|
| 65 |
.ls-cursor {
|
| 66 |
position: absolute;
|
|
|
|
| 61 |
max-width: 100%;
|
| 62 |
height: auto;
|
| 63 |
}
|
| 64 |
+
/* Stacked Verovio pages (a long score paginates into several SVGs). Each page is a
|
| 65 |
+
block so they stack vertically; a small gap separates pages. */
|
| 66 |
+
.ls-svg .ls-svg-page {
|
| 67 |
+
display: block;
|
| 68 |
+
margin: 0 auto 8px;
|
| 69 |
+
}
|
| 70 |
+
.ls-svg .ls-svg-page:last-of-type {
|
| 71 |
+
margin-bottom: 0;
|
| 72 |
+
}
|
| 73 |
/* Playback cursor: a vertical line tracking the currently-sounding note. */
|
| 74 |
.ls-cursor {
|
| 75 |
position: absolute;
|
web/score-player.js
CHANGED
|
@@ -92,14 +92,24 @@
|
|
| 92 |
return { mei, measureCount: (doc.measures && doc.measures.length) || 1, staffCount };
|
| 93 |
}
|
| 94 |
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
const parser = new DOMParser();
|
| 97 |
-
const doc = parser.parseFromString(svgString, 'image/svg+xml');
|
| 98 |
-
if (doc.querySelector('parsererror')) { log('svg parse error'); return; }
|
| 99 |
-
const svg = doc.querySelector('svg');
|
| 100 |
-
if (!svg) return;
|
| 101 |
els.svg.innerHTML = '';
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
// re-attach the playback cursor (innerHTML reset above removed it)
|
| 104 |
if (els.cursor) { els.cursor.style.display = 'none'; els.svg.appendChild(els.cursor); }
|
| 105 |
}
|
|
@@ -132,10 +142,15 @@
|
|
| 132 |
setStatus('Rendering…', 'busy');
|
| 133 |
try {
|
| 134 |
const { mei, measureCount, staffCount } = lylToMei(code);
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
| 137 |
if (!tk.loadData(mei)) { setStatus('Verovio load failed', 'err'); return false; }
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
| 139 |
state.lastCode = code;
|
| 140 |
state.lastMei = mei;
|
| 141 |
setStatus('', '');
|
|
@@ -229,14 +244,31 @@
|
|
| 229 |
}
|
| 230 |
|
| 231 |
async function play () {
|
| 232 |
-
if (
|
| 233 |
-
//
|
| 234 |
-
//
|
| 235 |
-
//
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
var A0 = state.audio && state.audio.MidiAudio;
|
| 238 |
-
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
state.isPlaying = true;
|
| 241 |
if (state.pausedTime > 0) {
|
| 242 |
state.playStartTime = performance.now() - state.pausedTime;
|
|
@@ -397,6 +429,11 @@
|
|
| 397 |
// GM soundfont loads (grand-piano fallback active), then a steady green 🎹✓ once
|
| 398 |
// ready (left in place, no fade). FluidSynth-only; if the backend lacks the
|
| 399 |
// loading/ready introspection (plain music-widgets MidiAudio) the badge stays hidden.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
function startSfStatus () {
|
| 401 |
var F = state.audio && state.audio.MidiAudio;
|
| 402 |
if (!els.sf || !F || !F.ready || !F.loading) return; // not the fluid backend
|
|
@@ -407,11 +444,34 @@
|
|
| 407 |
els.sf.classList.add('ready');
|
| 408 |
els.sf.title = 'Sound library ready — full instrument timbres';
|
| 409 |
}
|
| 410 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
var iv = setInterval(function () {
|
| 412 |
-
|
|
|
|
|
|
|
| 413 |
}, 200);
|
| 414 |
state._sfInterval = iv;
|
|
|
|
|
|
|
| 415 |
}
|
| 416 |
|
| 417 |
// Show the player bar only when: not generating, a score is rendered, audio
|
|
@@ -420,10 +480,18 @@
|
|
| 420 |
if (!els.player) return;
|
| 421 |
const show = !state.generating && !!state.lastMei;
|
| 422 |
els.player.style.display = show ? '' : 'none';
|
| 423 |
-
if (show)
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
}
|
| 428 |
|
| 429 |
// ---- mount + public API -------------------------------------------------
|
|
|
|
| 92 |
return { mei, measureCount: (doc.measures && doc.measures.length) || 1, staffCount };
|
| 93 |
}
|
| 94 |
|
| 95 |
+
// Inject one or more page SVGs, stacked vertically. Verovio paginates a long score
|
| 96 |
+
// into multiple pages (getPageCount); rendering only page 1 truncates the score, so
|
| 97 |
+
// we render every page and append them all. Element IDs are unique across pages, so
|
| 98 |
+
// the playback cursor/highlight lookups (getElementById) still work.
|
| 99 |
+
function injectSvg (svgStrings) {
|
| 100 |
+
if (typeof svgStrings === 'string') svgStrings = [svgStrings];
|
| 101 |
const parser = new DOMParser();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
els.svg.innerHTML = '';
|
| 103 |
+
for (let i = 0; i < svgStrings.length; i++) {
|
| 104 |
+
const doc = parser.parseFromString(svgStrings[i], 'image/svg+xml');
|
| 105 |
+
if (doc.querySelector('parsererror')) { log('svg parse error on page ' + (i + 1)); continue; }
|
| 106 |
+
const svg = doc.querySelector('svg');
|
| 107 |
+
if (!svg) continue;
|
| 108 |
+
const page = document.importNode(svg, true);
|
| 109 |
+
page.classList.add('ls-svg-page');
|
| 110 |
+
page.style.display = 'block';
|
| 111 |
+
els.svg.appendChild(page);
|
| 112 |
+
}
|
| 113 |
// re-attach the playback cursor (innerHTML reset above removed it)
|
| 114 |
if (els.cursor) { els.cursor.style.display = 'none'; els.svg.appendChild(els.cursor); }
|
| 115 |
}
|
|
|
|
| 142 |
setStatus('Rendering…', 'busy');
|
| 143 |
try {
|
| 144 |
const { mei, measureCount, staffCount } = lylToMei(code);
|
| 145 |
+
// Use a normal A4-ish page so Verovio lays the score out across pages; we then
|
| 146 |
+
// render ALL pages and stack them (see injectSvg). adjustPageHeight trims each
|
| 147 |
+
// page to its content so there are no big gaps between pages.
|
| 148 |
+
tk.setOptions({ scale: 40, adjustPageHeight: true, breaks: 'auto', pageWidth: 2100, pageHeight: 2970 });
|
| 149 |
if (!tk.loadData(mei)) { setStatus('Verovio load failed', 'err'); return false; }
|
| 150 |
+
const pageCount = (tk.getPageCount && tk.getPageCount()) || 1;
|
| 151 |
+
const pages = [];
|
| 152 |
+
for (let pg = 1; pg <= pageCount; pg++) pages.push(tk.renderToSVG(pg));
|
| 153 |
+
injectSvg(pages);
|
| 154 |
state.lastCode = code;
|
| 155 |
state.lastMei = mei;
|
| 156 |
setStatus('', '');
|
|
|
|
| 244 |
}
|
| 245 |
|
| 246 |
async function play () {
|
| 247 |
+
if (state.isPlaying || state.generating) return;
|
| 248 |
+
// Don't start a silent walk-through: if no audio backend can sound yet (neither
|
| 249 |
+
// the legacy piano fallback nor the full GM soundfont is ready), bail. The play
|
| 250 |
+
// button is normally disabled in this state, but guard here too.
|
| 251 |
+
var Af = state.audio && state.audio.MidiAudio;
|
| 252 |
+
if (Af && Af.empty && Af.empty()) { return; }
|
| 253 |
+
// Resume the audio backend FIRST, synchronously within the click gesture. The
|
| 254 |
+
// browser's autoplay policy only lets us resume a suspended AudioContext while a
|
| 255 |
+
// user-activation is in scope; if we first `await buildPlayer()` (async), the
|
| 256 |
+
// activation is gone by the time we'd resume, so the context stays suspended and
|
| 257 |
+
// no sound comes out. So kick resume() off now (don't await it yet).
|
| 258 |
var A0 = state.audio && state.audio.MidiAudio;
|
| 259 |
+
var resumeP = null;
|
| 260 |
+
if (A0 && A0.resume) { try { resumeP = A0.resume(); } catch (e) {} }
|
| 261 |
+
else { var WA = A0 && A0.WebAudio; if (WA && WA.awaitWarmup) { try { resumeP = WA.awaitWarmup(); } catch (e) {} } }
|
| 262 |
+
// The player may not be built yet: on a deep-link load the first render can run
|
| 263 |
+
// before the audio backend finished initialising, so updatePlayerVisibility's
|
| 264 |
+
// buildPlayer() returned early and was never retried (the editor text didn't
|
| 265 |
+
// change afterwards to re-trigger render). Build it on demand here.
|
| 266 |
+
if (!state.player || !state.midiData) {
|
| 267 |
+
if (!(await buildPlayer())) return; // still can't build (no MEI/toolkit) → nothing to play
|
| 268 |
+
}
|
| 269 |
+
if (!state.player || !state.midiData) return;
|
| 270 |
+
// now make sure the resume actually completed before scheduling notes
|
| 271 |
+
if (resumeP) { try { await resumeP; } catch (e) {} }
|
| 272 |
state.isPlaying = true;
|
| 273 |
if (state.pausedTime > 0) {
|
| 274 |
state.playStartTime = performance.now() - state.pausedTime;
|
|
|
|
| 429 |
// GM soundfont loads (grand-piano fallback active), then a steady green 🎹✓ once
|
| 430 |
// ready (left in place, no fade). FluidSynth-only; if the backend lacks the
|
| 431 |
// loading/ready introspection (plain music-widgets MidiAudio) the badge stays hidden.
|
| 432 |
+
//
|
| 433 |
+
// This also gates the play button: until SOME backend can sound (the legacy piano
|
| 434 |
+
// fallback OR the full GM soundfont — i.e. !empty()), play is disabled so the user
|
| 435 |
+
// can't trigger a silent walk-through. The button enables the moment a backend is
|
| 436 |
+
// audible and the badge turns green once the full GM font is ready.
|
| 437 |
function startSfStatus () {
|
| 438 |
var F = state.audio && state.audio.MidiAudio;
|
| 439 |
if (!els.sf || !F || !F.ready || !F.loading) return; // not the fluid backend
|
|
|
|
| 444 |
els.sf.classList.add('ready');
|
| 445 |
els.sf.title = 'Sound library ready — full instrument timbres';
|
| 446 |
}
|
| 447 |
+
// reflect current backend-audible state on the play button, and recover from a
|
| 448 |
+
// build race: if a backend is now audible but the player wasn't built yet (the
|
| 449 |
+
// first buildPlayer ran before audio was ready), build it now so play enables.
|
| 450 |
+
function syncPlayEnabled () {
|
| 451 |
+
if (!els.playBtn) return;
|
| 452 |
+
var audible = F.empty ? !F.empty() : true; // any backend can sound
|
| 453 |
+
if (audible && state.lastMei && !(state.player && state.midiData) && !state._buildingPlayer) {
|
| 454 |
+
state._buildingPlayer = true;
|
| 455 |
+
buildPlayer().then(function (ok) {
|
| 456 |
+
state._buildingPlayer = false;
|
| 457 |
+
if (ok) updateProgress();
|
| 458 |
+
});
|
| 459 |
+
}
|
| 460 |
+
els.playBtn.disabled = !(audible && state.player && state.midiData);
|
| 461 |
+
els.playBtn.title = audible ? 'Play' : 'Loading sound library…';
|
| 462 |
+
}
|
| 463 |
+
syncPlayEnabled();
|
| 464 |
+
// keep polling until BOTH a backend is audible and the player is built (covers
|
| 465 |
+
// the legacy-fallback window and the build race), and until the GM font is ready
|
| 466 |
+
// (for the badge). Stop once everything's settled.
|
| 467 |
var iv = setInterval(function () {
|
| 468 |
+
syncPlayEnabled();
|
| 469 |
+
if (F.ready()) markReady();
|
| 470 |
+
if (F.ready() && state.player && state.midiData) { clearInterval(iv); state._sfInterval = null; }
|
| 471 |
}, 200);
|
| 472 |
state._sfInterval = iv;
|
| 473 |
+
// safety: never poll forever
|
| 474 |
+
setTimeout(function () { if (state._sfInterval) { clearInterval(state._sfInterval); state._sfInterval = null; } }, 60000);
|
| 475 |
}
|
| 476 |
|
| 477 |
// Show the player bar only when: not generating, a score is rendered, audio
|
|
|
|
| 480 |
if (!els.player) return;
|
| 481 |
const show = !state.generating && !!state.lastMei;
|
| 482 |
els.player.style.display = show ? '' : 'none';
|
| 483 |
+
if (show) {
|
| 484 |
+
// disable play until a backend is audible AND the player is built; the
|
| 485 |
+
// sf-status poller (startSfStatus) keeps this in sync as soundfonts load.
|
| 486 |
+
if (els.playBtn) els.playBtn.disabled = true;
|
| 487 |
+
buildPlayer().then(function (ok) {
|
| 488 |
+
if (ok) updateProgress();
|
| 489 |
+
var F = state.audio && state.audio.MidiAudio;
|
| 490 |
+
var audible = (F && F.empty) ? !F.empty() : true;
|
| 491 |
+
els.playBtn.disabled = !(ok && audible);
|
| 492 |
+
startSfStatus(); // keep enabling/badge in sync while soundfonts finish loading
|
| 493 |
+
});
|
| 494 |
+
}
|
| 495 |
}
|
| 496 |
|
| 497 |
// ---- mount + public API -------------------------------------------------
|