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

fixed sound library loading issue.

Browse files
Files changed (3) hide show
  1. app.py +26 -0
  2. web/fluid-audio.js +41 -8
  3. web/score-player.js +23 -5
app.py CHANGED
@@ -381,6 +381,32 @@ function () {
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
 
381
  setTimeout(() => clearInterval(iv), 20000);
382
  }
383
 
384
+ // Initial render on RELOAD/restore. Gradio repopulates the hidden #ls-editor-state
385
+ // textbox with the previous score on a page reload but does NOT fire a `change`
386
+ // event, so the normal change->render path never runs and the sheet stays blank.
387
+ // This is self-healing against the reload race (mount, Gradio's textbox restore,
388
+ // and Verovio init all settle at different times): poll until an SVG has actually
389
+ // appeared, re-calling render each tick while the editor has text but no SVG is
390
+ // shown yet. render() has a lastCode no-op guard, so repeat calls with the same
391
+ // text are cheap once it has succeeded. On a FRESH deep-link load the editor
392
+ // starts empty and the radio-click path renders it, so the SVG shows up and this
393
+ // loop stops without ever calling render itself (no double render).
394
+ {
395
+ let rtries = 0;
396
+ const initialRender = () => {
397
+ const root = document.getElementById('ls-score');
398
+ const shown = root && root.querySelector('.ls-svg svg');
399
+ if (shown) return; // something rendered — done
400
+ const ta = document.querySelector('#ls-editor-state textarea');
401
+ const txt = ta && ta.value;
402
+ if (window.LilyScore && root && txt && txt.trim()) {
403
+ try { window.LilyScore.render(txt); } catch (e) {}
404
+ }
405
+ if (++rtries < 100) setTimeout(initialRender, 150);
406
+ };
407
+ initialRender();
408
+ }
409
+
410
  // Deep-link: if the URL carries #score=<file>, select that Score List entry on
411
  // first load (so a bookmarked/shared score opens directly). We strip the leading
412
  // emoji prefix (📄/✨) when comparing, and click the matching radio input — that
web/fluid-audio.js CHANGED
@@ -32,6 +32,12 @@
32
  var FLUID_URL = (window.__LILYSCRIPT_FLUID_URL || './fluid/');
33
  var SOUNDFONT_URL = (window.__LILYSCRIPT_SOUNDFONT_URL || './soundfont/');
34
 
 
 
 
 
 
 
35
  // The with-libsndfile build is required to decode .sf3 (ogg-compressed) fonts;
36
  // it also reads .sf2. Both modules load into the AudioWorklet scope.
37
  var LIBFLUIDSYNTH_FILE = FLUID_URL + 'libfluidsynth-2.4.6-with-libsndfile.js';
@@ -87,6 +93,28 @@
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);
@@ -98,13 +126,19 @@
98
  node = synth.createAudioNode(audioCtx);
99
  node.connect(audioCtx.destination);
100
 
101
- var sfontBuffer = await (await fetch(SOUNDFONT_FILE)).arrayBuffer();
102
  await synth.loadSFont(sfontBuffer);
103
 
104
  seq = await synth.createSequencer();
105
  await seq.registerSynthesizer(synth);
106
  seq.setTimeScale(1000); // 1 tick = 1 ms, matching performance.now()
107
 
 
 
 
 
 
 
 
108
  // Hand off from the fallback: silence any lingering notes.
109
  if (legacyReady && legacy && legacy.stopAllNotes) legacy.stopAllNotes();
110
 
@@ -115,13 +149,12 @@
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') {
 
32
  var FLUID_URL = (window.__LILYSCRIPT_FLUID_URL || './fluid/');
33
  var SOUNDFONT_URL = (window.__LILYSCRIPT_SOUNDFONT_URL || './soundfont/');
34
 
35
+ // DEBUG TOGGLE: when true, skip loading gm.sf3 / FluidSynth entirely and keep
36
+ // playing through the legacy ogg.js piano fallback. Lets us isolate whether the
37
+ // fallback path alone is audible, independent of the FluidSynth handoff. Set via
38
+ // window.__LILYSCRIPT_DISABLE_FLUID before this script runs, or flip the default.
39
+ var DISABLE_FLUID = !!window.__LILYSCRIPT_DISABLE_FLUID;
40
+
41
  // The with-libsndfile build is required to decode .sf3 (ogg-compressed) fonts;
42
  // it also reads .sf2. Both modules load into the AudioWorklet scope.
43
  var LIBFLUIDSYNTH_FILE = FLUID_URL + 'libfluidsynth-2.4.6-with-libsndfile.js';
 
93
  // 2) Wait for the fallback to finish before kicking off the heavy GM load.
94
  try { await legacyPromise; } catch (e) {}
95
 
96
+ // DEBUG: gm.sf3/FluidSynth disabled — stay on the legacy ogg.js fallback.
97
+ if (DISABLE_FLUID) {
98
+ log('FluidSynth DISABLED (DISABLE_FLUID) — using legacy ogg.js fallback only');
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
105
+ // only then downloaded the soundfont, TWO suspended AudioContexts (the legacy
106
+ // fallback's and FluidSynth's) would coexist during the long download window.
107
+ // The browser autoplay policy only lets us resume a suspended context
108
+ // synchronously inside a user gesture, and with two of them the single
109
+ // in-gesture resume() couldn't reliably warm BOTH — the legacy one lost and
110
+ // stayed silent (the "fallback plays but no sound until gm.sf3 loads" bug).
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.
118
  audioCtx = new (window.AudioContext || window.webkitAudioContext)();
119
  // libfluidsynth must be added before the worklet module.
120
  await audioCtx.audioWorklet.addModule(LIBFLUIDSYNTH_FILE);
 
126
  node = synth.createAudioNode(audioCtx);
127
  node.connect(audioCtx.destination);
128
 
 
129
  await synth.loadSFont(sfontBuffer);
130
 
131
  seq = await synth.createSequencer();
132
  await seq.registerSynthesizer(synth);
133
  seq.setTimeScale(1000); // 1 tick = 1 ms, matching performance.now()
134
 
135
+ // FluidSynth's context is created fresh here, AFTER the user has likely
136
+ // already pressed play (gm.sf3 takes seconds to fetch+decode). Its context
137
+ // therefore starts suspended with no pending user gesture to resume it. Try
138
+ // to resume right away; if the autoplay policy blocks it, the next play()
139
+ // click resumes it synchronously in-gesture and the handoff completes then.
140
+ if (audioCtx.state === 'suspended') { try { await audioCtx.resume(); } catch (e) {} }
141
+
142
  // Hand off from the fallback: silence any lingering notes.
143
  if (legacyReady && legacy && legacy.stopAllNotes) legacy.stopAllNotes();
144
 
 
149
  }
150
 
151
  // Resume audio output; must be called from a user gesture (autoplay policy).
152
+ // A suspended AudioContext can only be resumed *synchronously within a user
153
+ // gesture*, so we fire all resume()/awaitWarmup() calls BEFORE the first await.
154
+ // During the fallback window `audioCtx` is null (FluidSynth's context isn't
155
+ // created until gm.sf3 is fetched see loadPlugin), so only the legacy WebAudio
156
+ // context exists and gets warmed up here; there's no second context to contend
157
+ // with. Once gm.sf3 has loaded, audioCtx exists and is resumed too.
 
158
  function resume () {
159
  var promises = [];
160
  if (audioCtx && audioCtx.state === 'suspended') {
web/score-player.js CHANGED
@@ -199,9 +199,15 @@
199
  }
200
  if (backend.empty && backend.empty()) { log('audio not ready (no backend audible)'); return false; }
201
  state.audioReady = true;
202
- // warm up eagerly (the in-gesture resume() in play() is what actually
203
- // satisfies the browser autoplay policy).
204
- if (backend.resume) { try { await backend.resume(); } catch (e) {} }
 
 
 
 
 
 
205
  log('audio ready (fluid=' + (backend.ready ? backend.ready() : '?') + ')');
206
  return true;
207
  } catch (e) {
@@ -267,8 +273,20 @@
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;
 
199
  }
200
  if (backend.empty && backend.empty()) { log('audio not ready (no backend audible)'); return false; }
201
  state.audioReady = true;
202
+ // NOTE: do NOT warm up / resume the audio context here. This runs at page
203
+ // load, outside any user gesture, so the browser autoplay policy won't
204
+ // actually resume a suspended context. Worse, the legacy WebAudio backend's
205
+ // awaitWarmup() *caches* the in-flight resume() promise — calling it
206
+ // out-of-gesture starts a resume() that never settles (no gesture) and
207
+ // caches that permanently-pending promise. Every later in-gesture warmup
208
+ // then returns the same hung promise, so play()'s `await resumeP` blocks
209
+ // forever and the transport freezes ("no reaction"). The in-gesture
210
+ // resume() inside play() is the ONLY place we resume.
211
  log('audio ready (fluid=' + (backend.ready ? backend.ready() : '?') + ')');
212
  return true;
213
  } catch (e) {
 
273
  if (!(await buildPlayer())) return; // still can't build (no MEI/toolkit) → nothing to play
274
  }
275
  if (!state.player || !state.midiData) return;
276
+ // Make sure the resume completed before scheduling notes — but never let a hung
277
+ // resume freeze the transport. (The legacy backend can return a resume promise
278
+ // that never settles; see the awaitWarmup note in initAudio.) Race it against a
279
+ // short timeout: notes start scheduling regardless, and sound follows as soon as
280
+ // the context actually resumes. Resuming is in-gesture above, so this is just a
281
+ // safety valve, not the thing that satisfies autoplay.
282
+ if (resumeP) {
283
+ try {
284
+ await Promise.race([
285
+ Promise.resolve(resumeP).catch(function () {}),
286
+ new Promise(function (r) { setTimeout(r, 250); }),
287
+ ]);
288
+ } catch (e) {}
289
+ }
290
  state.isPlaying = true;
291
  if (state.pausedTime > 0) {
292
  state.playStartTime = performance.now() - state.pausedTime;