Spaces:
Running
Running
Commit ·
2a7be55
1
Parent(s): 3225df7
fixed sound library loading issue.
Browse files- app.py +26 -0
- web/fluid-audio.js +41 -8
- 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 |
-
//
|
| 119 |
-
//
|
| 120 |
-
//
|
| 121 |
-
//
|
| 122 |
-
//
|
| 123 |
-
//
|
| 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
|
| 203 |
-
//
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|