k-l-lambda commited on
Commit
3225df7
·
1 Parent(s): 94fe5c1

added URL hash for loaded score file.

Browse files
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 by a small ResizeObserver
475
- in web/layout-fit.js (sets --ls-fill-h on :root = viewport − the panels above the
476
- fill row). These rules consume that variable. */
 
 
 
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 viewport-capped
481
- height instead of growing the row. */
482
- #sheet-col {
483
- position: sticky;
484
- top: 8px;
485
- }
486
  #sheet-col .ls-score-root {
487
- height: calc(100vh - 110px);
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 the height left under Compose + Logs,
500
- computed by layout-fit.js into --ls-fill-h (with a sane fallback + floor). */
501
  #compose-col > .lp-fill {
502
- height: var(--ls-fill-h, 420px);
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", "Choral", "Chamber", "Orchestral"]
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
- // Start the lightweight legacy piano fallback immediately so playback is
73
- // audible while the large FluidSynth soundfont downloads. Non-fatal on error.
 
 
 
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 if (legacy) {
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
- async function resume () {
112
- if (audioCtx && audioCtx.state === 'suspended') { try { await audioCtx.resume(); } catch (e) {} }
 
 
 
 
 
 
 
 
 
 
113
  var WA = legacy && legacy.WebAudio;
114
- if (legacyReady && WA && WA.needsWarmup && WA.needsWarmup() && WA.awaitWarmup) {
115
- try { await WA.awaitWarmup(); } catch (e) {}
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
- * Gradio's deep wrapper nesting makes pure-CSS flex height propagation unreliable
4
- * (a long score in the right panel, or the editor's own content, can blow out the
5
- * left column). So we compute the height available to the bottom "Score List |
6
- * editor" row in JS and expose it as the CSS variable --ls-fill-h on :root.
7
  *
8
- * --ls-fill-h = viewport_height - fillRow.top - bottom_gap
9
  *
10
- * i.e. the fill row gets exactly the space left under Compose + Logs, down to the
11
- * bottom of the viewport. score-player.css / app.py consume the variable. We
12
- * recompute on resize and whenever the left column's size changes (Logs expanding,
13
- * accordion toggle, fonts loading) via a ResizeObserver.
 
 
 
 
 
 
14
  */
15
  (function () {
16
  'use strict';
17
 
18
- var BOTTOM_GAP = 16; // breathing room below the fill row
19
- var MIN_FILL = 360; // never collapse the editor/score-list below this
 
 
 
 
 
 
 
 
 
 
 
 
 
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; // viewport-relative
30
  var avail = window.innerHeight - top - BOTTOM_GAP;
31
  if (avail < MIN_FILL) avail = MIN_FILL;
32
- document.documentElement.style.setProperty('--ls-fill-h', avail + 'px');
 
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
- // also observe the two fixed blocks directly (their height drives fill.top)
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
- // a late pass after fonts/score player settle
63
- setTimeout(recompute, 1200);
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
- function injectSvg (svgString) {
 
 
 
 
 
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
- els.svg.appendChild(document.importNode(svg, true));
 
 
 
 
 
 
 
 
 
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
- const pageHeight = Math.max(2000, Math.ceil(measureCount / 20) * 2000) * 2 * staffCount;
136
- tk.setOptions({ scale: 40, adjustPageHeight: true, pageWidth: 2100, pageHeight: pageHeight });
 
 
137
  if (!tk.loadData(mei)) { setStatus('Verovio load failed', 'err'); return false; }
138
- injectSvg(tk.renderToSVG(1));
 
 
 
139
  state.lastCode = code;
140
  state.lastMei = mei;
141
  setStatus('', '');
@@ -229,14 +244,31 @@
229
  }
230
 
231
  async function play () {
232
- if (!state.player || !state.midiData || state.isPlaying || state.generating) return;
233
- // warm up / resume the audio backend (browser autoplay policy) inside the play
234
- // gesture before scheduling notes, so the opening notes aren't dropped on first
235
- // play. LilyFluidAudio.resume() resumes the FluidSynth AudioContext (and warms
236
- // the legacy fallback); fall back to the old WebAudio.awaitWarmup path.
 
 
 
 
 
 
237
  var A0 = state.audio && state.audio.MidiAudio;
238
- if (A0 && A0.resume) { try { await A0.resume(); } catch (e) {} }
239
- else { var WA = A0 && A0.WebAudio; if (WA && WA.awaitWarmup) { try { await WA.awaitWarmup(); } catch (e) {} } }
 
 
 
 
 
 
 
 
 
 
 
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
- if (F.ready()) { markReady(); return; } // fast path (cached)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  var iv = setInterval(function () {
412
- if (F.ready()) { clearInterval(iv); state._sfInterval = null; markReady(); }
 
 
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) buildPlayer().then(function (ok) {
424
- if (ok) updateProgress();
425
- els.playBtn.disabled = !ok;
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 -------------------------------------------------