k-l-lambda commited on
Commit
78f88e1
·
1 Parent(s): 5f1356b

player: FluidSynth (GM soundfont) audio backend with piano fallback

Browse files

Replace the single-piano MIDI.js soundfont with FluidSynth (js-synthesizer
WASM) playing a 128-instrument General MIDI gm.sf3, so each MIDI program
sounds with its own timbre instead of everything-as-piano. The legacy
grand-piano soundfont stays as an immediate fallback while the ~40MB GM
font downloads, then FluidSynth takes over transparently.

- web/fluid-audio.js: vanilla-JS port of live-editor's fluidAudio.ts,
window.LilyFluidAudio (init/loadPlugin/resume/noteOn/noteOff/
programChange/stopAllNotes), routing to FluidSynth's Sequencer with the
legacy MidiAudio piano as fallback.
- web/fluid/, web/vendor/js-synthesizer.min.js, web/soundfont/gm.sf3:
vendored FluidSynth runtime + GM soundfont (LFS).
- score-player.js: initAudio drives LilyFluidAudio; reports ready as soon
as any backend is audible; play() resume()s the AudioContext. Adds a
sound-library status badge (pulsing while loading, steady green when
ready).
- app.py: build_head injects js-synthesizer + fluid-audio.js and the
fluid/soundfont base-URL globals.

.gitattributes CHANGED
@@ -39,3 +39,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
39
  # stays a normal text file.
40
  web/vendor/*.js filter=lfs diff=lfs merge=lfs -text
41
  web/soundfont/*.js filter=lfs diff=lfs merge=lfs -text
 
 
 
 
39
  # stays a normal text file.
40
  web/vendor/*.js filter=lfs diff=lfs merge=lfs -text
41
  web/soundfont/*.js filter=lfs diff=lfs merge=lfs -text
42
+ # FluidSynth (js-synthesizer) runtime + General-MIDI soundfont for the player.
43
+ web/fluid/*.js filter=lfs diff=lfs merge=lfs -text
44
+ web/soundfont/*.sf3 filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -14,3 +14,4 @@ __pycache__/
14
  .DS_Store
15
 
16
  *.local
 
 
14
  .DS_Store
15
 
16
  *.local
17
+ *.bak-*
app.py CHANGED
@@ -339,14 +339,21 @@ def build_head ():
339
  os.path.join(vendor, 'lilylet.bundle.js'),
340
  os.path.join(vendor, 'verovio.bundle.js'),
341
  os.path.join(vendor, 'musicWidgetsBrowser.umd.min.js'),
 
 
 
 
 
 
342
  os.path.join(WEB_DIR, 'score-player.js'),
343
  # our own CodeMirror 6 editor (CM + grammar-derived lilylet() highlighter) and
344
  # the mount/bridge script that wires it to the hidden #ls-editor-state textbox.
345
  os.path.join(vendor, 'lyl-editor.bundle.js'),
346
  os.path.join(WEB_DIR, 'lyl-editor-mount.js'),
347
  ]
348
- tags = ['<script>window.__LILYSCRIPT_SOUNDFONT_URL=%r;</script>'
349
- % (_file_url(os.path.join(WEB_DIR, 'soundfont')) + '/')]
 
350
  tags.append('<link rel="stylesheet" href="%s">' % _file_url(os.path.join(WEB_DIR, 'score-player.css')))
351
  tags.append('<link rel="stylesheet" href="%s">' % _file_url(os.path.join(WEB_DIR, 'lyl-editor.css')))
352
  for s in scripts:
 
339
  os.path.join(vendor, 'lilylet.bundle.js'),
340
  os.path.join(vendor, 'verovio.bundle.js'),
341
  os.path.join(vendor, 'musicWidgetsBrowser.umd.min.js'),
342
+ # FluidSynth audio backend: js-synthesizer UMD (window.JSSynth) + our adapter
343
+ # (window.LilyFluidAudio). Must precede score-player.js, which calls
344
+ # LilyFluidAudio.init() in initAudio(). The libfluidsynth runtime + gm.sf3 are
345
+ # loaded on demand by the adapter from web/fluid/ and web/soundfont/.
346
+ os.path.join(vendor, 'js-synthesizer.min.js'),
347
+ os.path.join(WEB_DIR, 'fluid-audio.js'),
348
  os.path.join(WEB_DIR, 'score-player.js'),
349
  # our own CodeMirror 6 editor (CM + grammar-derived lilylet() highlighter) and
350
  # the mount/bridge script that wires it to the hidden #ls-editor-state textbox.
351
  os.path.join(vendor, 'lyl-editor.bundle.js'),
352
  os.path.join(WEB_DIR, 'lyl-editor-mount.js'),
353
  ]
354
+ tags = ['<script>window.__LILYSCRIPT_SOUNDFONT_URL=%r;window.__LILYSCRIPT_FLUID_URL=%r;</script>'
355
+ % (_file_url(os.path.join(WEB_DIR, 'soundfont')) + '/',
356
+ _file_url(os.path.join(WEB_DIR, 'fluid')) + '/')]
357
  tags.append('<link rel="stylesheet" href="%s">' % _file_url(os.path.join(WEB_DIR, 'score-player.css')))
358
  tags.append('<link rel="stylesheet" href="%s">' % _file_url(os.path.join(WEB_DIR, 'lyl-editor.css')))
359
  for s in scripts:
web/fluid-audio.js ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* LilyScript FluidSynth audio backend — vanilla-JS port of lilylet-live-editor's
2
+ * src/lib/audio/fluidAudio.ts, exposed as window.LilyFluidAudio.
3
+ *
4
+ * Replaces the legacy single-piano MIDI.js soundfont with FluidSynth (js-synthesizer
5
+ * WASM) playing a General MIDI gm.sf3 (128 instruments), so each track sounds with
6
+ * its own program instead of everything-as-piano.
7
+ *
8
+ * Shaped to match the subset of music-widgets' MidiAudio API that score-player.js
9
+ * drives: empty / loadPlugin / resume / noteOn / noteOff / programChange /
10
+ * stopAllNotes. score-player.js sends noteOn/noteOff with an absolute
11
+ * performance.now()-based timestamp; we forward to FluidSynth's Sequencer via
12
+ * sendEventAt(event, dtMs, isAbsolute=false) ("play in dtMs ms"), preserving the
13
+ * look-ahead timing without reconciling against the sequencer's own tick origin.
14
+ *
15
+ * Fallback: gm.sf3 is ~40 MB and takes a moment to fetch + decode. While it loads we
16
+ * fall back to the legacy MIDI.js WebAudio piano (the small, locally-bundled
17
+ * acoustic-grand-piano soundfont) so playback is audible immediately; once FluidSynth
18
+ * is ready we switch to it transparently.
19
+ *
20
+ * Globals consumed (loaded via <script src> in <head> by app.py build_head):
21
+ * window.JSSynth js-synthesizer UMD { AudioWorkletNodeSynthesizer, ... }
22
+ * window.musicWidgetsBrowser.MidiAudio legacy MIDI.js WebAudio piano (fallback)
23
+ * window.__LILYSCRIPT_FLUID_URL base URL of web/fluid/ (libfluidsynth + worklet)
24
+ * window.__LILYSCRIPT_SOUNDFONT_URL base URL of web/soundfont/ (gm.sf3 + piano)
25
+ */
26
+ (function () {
27
+ 'use strict';
28
+
29
+ function log (msg) { console.log('[LilyFluid]', msg); }
30
+
31
+ // URLs (with trailing slash) injected by app.py; fall back to relative paths.
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';
38
+ var WORKLET_FILE = FLUID_URL + 'js-synthesizer.worklet.js';
39
+ var SOUNDFONT_FILE = SOUNDFONT_URL + 'gm.sf3';
40
+
41
+ var audioCtx = null;
42
+ var synth = null;
43
+ var seq = null;
44
+ var node = null; // the synth's AudioNode (kept for diagnostics)
45
+ var loaded = false;
46
+ var loadingPromise = null;
47
+
48
+ // Legacy MIDI.js fallback (single-piano), used until FluidSynth finishes loading.
49
+ var legacy = null;
50
+ var legacyReady = false;
51
+
52
+ // Inject the legacy UMD MidiAudio (fallback). Must be called before loadPlugin().
53
+ function init (legacyMidiAudio) {
54
+ legacy = legacyMidiAudio || null;
55
+ }
56
+
57
+ // True once the (preferred) FluidSynth backend has finished loading.
58
+ function ready () { return loaded; }
59
+
60
+ // True while FluidSynth is still loading (the fallback may already be audible).
61
+ function loading () { return !loaded && !!loadingPromise; }
62
+
63
+ // Whether any backend can produce sound yet (FluidSynth or the legacy fallback).
64
+ function empty () { return !loaded && !legacyReady; }
65
+
66
+ function delayFromNow (timestamp) { return Math.max(0, timestamp - performance.now()); }
67
+
68
+ function loadPlugin () {
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);
86
+ await audioCtx.audioWorklet.addModule(WORKLET_FILE);
87
+
88
+ synth = new window.JSSynth.AudioWorkletNodeSynthesizer();
89
+ synth.init(audioCtx.sampleRate);
90
+ // createAudioNode MUST be called before any other synth method.
91
+ node = synth.createAudioNode(audioCtx);
92
+ node.connect(audioCtx.destination);
93
+
94
+ var sfontBuffer = await (await fetch(SOUNDFONT_FILE)).arrayBuffer();
95
+ await synth.loadSFont(sfontBuffer);
96
+
97
+ seq = await synth.createSequencer();
98
+ await seq.registerSynthesizer(synth);
99
+ seq.setTimeScale(1000); // 1 tick = 1 ms, matching performance.now()
100
+
101
+ // Hand off from the fallback: silence any lingering notes.
102
+ if (legacyReady && legacy && legacy.stopAllNotes) legacy.stopAllNotes();
103
+
104
+ loaded = true;
105
+ log('FluidSynth soundfont loaded (gm.sf3)');
106
+ })();
107
+ return loadingPromise;
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) {
120
+ if (seq) { seq.sendEventAt({ type: 'noteon', channel: channel, key: note, vel: velocity }, delayFromNow(timestamp), false); return; }
121
+ if (legacyReady && legacy) legacy.noteOn(channel, note, velocity, timestamp);
122
+ }
123
+
124
+ function noteOff (channel, note, timestamp) {
125
+ if (seq) { seq.sendEventAt({ type: 'noteoff', channel: channel, key: note }, delayFromNow(timestamp), false); return; }
126
+ if (legacyReady && legacy) legacy.noteOff(channel, note, timestamp);
127
+ }
128
+
129
+ function programChange (channel, program) {
130
+ if (seq) { seq.sendEventAt({ type: 'programchange', channel: channel, preset: program }, 0, false); return; }
131
+ if (legacyReady && legacy) legacy.programChange(channel, program);
132
+ }
133
+
134
+ function stopAllNotes () {
135
+ if (seq) seq.removeAllEvents(); // drop the in-flight look-ahead window
136
+ if (synth) { for (var ch = 0; ch < 16; ++ch) synth.midiAllSoundsOff(ch); }
137
+ if (legacyReady && legacy && legacy.stopAllNotes) legacy.stopAllNotes();
138
+ }
139
+
140
+ window.LilyFluidAudio = {
141
+ init: init,
142
+ empty: empty,
143
+ ready: ready,
144
+ loading: loading,
145
+ loadPlugin: loadPlugin,
146
+ resume: resume,
147
+ noteOn: noteOn,
148
+ noteOff: noteOff,
149
+ programChange: programChange,
150
+ stopAllNotes: stopAllNotes,
151
+ // Lightweight diagnostics: backend/AudioContext state. (A scheduled note with
152
+ // no thrown error does NOT prove audio output — for that, tap node with an
153
+ // AnalyserNode in the console.) Handy when troubleshooting "no sound".
154
+ _debug: function () {
155
+ return {
156
+ loaded: loaded, legacyReady: legacyReady,
157
+ ctxState: audioCtx ? audioCtx.state : 'no-ctx',
158
+ hasSeq: !!seq, hasSynth: !!synth, hasNode: !!node,
159
+ };
160
+ },
161
+ };
162
+ log('fluid-audio.js loaded');
163
+ })();
web/fluid/js-synthesizer.worklet.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:93ba8c10322273523fa7de8c4b91d402692db8f24a52d7ca3bd31b99b5d06510
3
+ size 29391
web/fluid/libfluidsynth-2.4.6-with-libsndfile.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c108b7f00ed348892d46f9e6236618d76ae7f3d2b6cb860b2990c0c2ce9dc67c
3
+ size 2371527
web/score-player.css CHANGED
@@ -146,3 +146,41 @@
146
  background: #7c5cff;
147
  transition: width 0.1s linear;
148
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  background: #7c5cff;
147
  transition: width 0.1s linear;
148
  }
149
+
150
+ /* Sound-library status badge in the transport bar. While the GM soundfont loads:
151
+ dimmed + grayscaled + pulsing 🎹 (the small grand-piano fallback is audible).
152
+ Once ready: full-color 🎹 with a green ✓, shown 2s then faded out (hover reveals). */
153
+ #ls-score .ls-sf {
154
+ position: relative;
155
+ font-size: 14px;
156
+ line-height: 1;
157
+ margin-left: 2px;
158
+ flex: 0 0 auto;
159
+ cursor: default;
160
+ opacity: 0.45;
161
+ filter: grayscale(1);
162
+ animation: ls-sf-pulse 1.4s ease-in-out infinite;
163
+ }
164
+ #ls-score .ls-sf.ready {
165
+ opacity: 1;
166
+ filter: none;
167
+ animation: none;
168
+ }
169
+ @keyframes ls-sf-pulse {
170
+ 0%, 100% { opacity: 0.30; }
171
+ 50% { opacity: 0.65; }
172
+ }
173
+ /* green ✓ badge superscript on the ready piano */
174
+ #ls-score .ls-sf .ls-sf-check {
175
+ display: none;
176
+ position: absolute;
177
+ top: -0.4em;
178
+ right: -0.7em;
179
+ font-size: 0.85em;
180
+ font-weight: 700;
181
+ color: #3fb950;
182
+ text-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
183
+ }
184
+ #ls-score .ls-sf.ready .ls-sf-check {
185
+ display: inline;
186
+ }
web/score-player.js CHANGED
@@ -158,15 +158,36 @@
158
  if (state.audioReady) return true;
159
  const mw = window.musicWidgetsBrowser;
160
  if (!mw) { log('musicWidgetsBrowser missing'); return false; }
161
- state.audio = { MIDI: mw.MIDI, MidiPlayer: mw.MidiPlayer, MusicNotation: mw.MusicNotation, MidiAudio: mw.MidiAudio };
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  try {
163
- await state.audio.MidiAudio.loadPlugin({ soundfontUrl: SOUNDFONT_URL, api: 'webaudio' });
 
 
 
 
 
 
 
 
 
164
  state.audioReady = true;
165
- // warm up the AudioContext eagerly so later plays are already hot (the
166
- // in-gesture warmup in play() is what actually satisfies autoplay policy)
167
- var WA = state.audio.MidiAudio.WebAudio;
168
- if (WA && WA.awaitWarmup) { try { await WA.awaitWarmup(); } catch (e) {} }
169
- log('audio ready');
170
  return true;
171
  } catch (e) {
172
  log('audio load failed: ' + (e && e.message ? e.message : e));
@@ -209,10 +230,13 @@
209
 
210
  async function play () {
211
  if (!state.player || !state.midiData || state.isPlaying || state.generating) return;
212
- // warm up the AudioContext (browser autoplay policy) inside the play gesture
213
- // before scheduling notes, so the opening notes aren't dropped on first play
214
- var WA = state.audio && state.audio.MidiAudio && state.audio.MidiAudio.WebAudio;
215
- if (WA && WA.awaitWarmup) { try { await WA.awaitWarmup(); } catch (e) {} }
 
 
 
216
  state.isPlaying = true;
217
  if (state.pausedTime > 0) {
218
  state.playStartTime = performance.now() - state.pausedTime;
@@ -368,6 +392,28 @@
368
  els.pauseBtn.style.display = state.isPlaying ? '' : 'none';
369
  }
370
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  // Show the player bar only when: not generating, a score is rendered, audio
372
  // available. While generating we keep SVG visible but hide the transport.
373
  function updatePlayerVisibility () {
@@ -401,7 +447,10 @@
401
  '<button class="ls-btn ls-pause" title="Pause" style="display:none">⏸</button>' +
402
  '<button class="ls-btn ls-stop" title="Stop">■</button>' +
403
  '<span class="ls-time">0:00 / 0:00</span>' +
404
- '<div class="ls-progress"><div class="ls-fill"></div></div>';
 
 
 
405
 
406
  root.appendChild(player); root.appendChild(wrap);
407
 
@@ -412,6 +461,7 @@
412
  els.time = player.querySelector('.ls-time');
413
  els.progress = player.querySelector('.ls-progress');
414
  els.fill = player.querySelector('.ls-fill');
 
415
 
416
  els.playBtn.addEventListener('click', play);
417
  els.pauseBtn.addEventListener('click', pause);
 
158
  if (state.audioReady) return true;
159
  const mw = window.musicWidgetsBrowser;
160
  if (!mw) { log('musicWidgetsBrowser missing'); return false; }
161
+ // MIDI/MidiPlayer/MusicNotation come from music-widgets; the *audio backend* is
162
+ // FluidSynth (js-synthesizer + gm.sf3, 128 GM instruments) via LilyFluidAudio,
163
+ // which falls back to music-widgets' single-piano MidiAudio while the large GM
164
+ // soundfont downloads. score-player drives MidiAudio.{noteOn,noteOff,
165
+ // programChange,stopAllNotes,resume}, all forwarded by LilyFluidAudio.
166
+ var backend = window.LilyFluidAudio;
167
+ if (backend && backend.init) {
168
+ backend.init(mw.MidiAudio); // inject the legacy piano fallback
169
+ } else {
170
+ log('LilyFluidAudio missing; using music-widgets MidiAudio directly');
171
+ backend = mw.MidiAudio;
172
+ }
173
+ state.audio = { MIDI: mw.MIDI, MidiPlayer: mw.MidiPlayer, MusicNotation: mw.MusicNotation, MidiAudio: backend };
174
+ startSfStatus(); // show the sound-library loading badge while the GM font downloads
175
  try {
176
+ // Kick off loading; resolves only when the full GM soundfont is decoded.
177
+ // Don't block playback on it — the legacy piano fallback becomes audible
178
+ // much earlier, so report ready as soon as any backend can sound.
179
+ var fullLoad = backend.loadPlugin ? backend.loadPlugin({ soundfontUrl: SOUNDFONT_URL, api: 'webaudio' }) : Promise.resolve();
180
+ if (backend.empty) {
181
+ for (var i = 0; i < 100 && backend.empty(); i++) { await new Promise(function (r) { setTimeout(r, 100); }); }
182
+ } else {
183
+ await fullLoad;
184
+ }
185
+ if (backend.empty && backend.empty()) { log('audio not ready (no backend audible)'); return false; }
186
  state.audioReady = true;
187
+ // warm up eagerly (the in-gesture resume() in play() is what actually
188
+ // satisfies the browser autoplay policy).
189
+ if (backend.resume) { try { await backend.resume(); } catch (e) {} }
190
+ log('audio ready (fluid=' + (backend.ready ? backend.ready() : '?') + ')');
 
191
  return true;
192
  } catch (e) {
193
  log('audio load failed: ' + (e && e.message ? e.message : e));
 
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;
 
392
  els.pauseBtn.style.display = state.isPlaying ? '' : 'none';
393
  }
394
 
395
+ // ---- sound-library (soundfont) status badge -----------------------------
396
+ // Mirrors live-editor's Preview.svelte sf-status: pulsing/grayscale 🎹 while the
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
403
+ if (state._sfInterval) return; // already tracking
404
+ els.sf.style.display = '';
405
+ els.sf.classList.remove('ready');
406
+ function markReady () {
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
418
  // available. While generating we keep SVG visible but hide the transport.
419
  function updatePlayerVisibility () {
 
447
  '<button class="ls-btn ls-pause" title="Pause" style="display:none">⏸</button>' +
448
  '<button class="ls-btn ls-stop" title="Stop">■</button>' +
449
  '<span class="ls-time">0:00 / 0:00</span>' +
450
+ '<div class="ls-progress"><div class="ls-fill"></div></div>' +
451
+ // sound-library status: pulsing 🎹 while the GM soundfont loads (piano
452
+ // fallback audible), green 🎹✓ once ready (shown 2s, then faded; hover reveals).
453
+ '<span class="ls-sf" style="display:none" title="Loading sound library… (grand-piano fallback active)">🎹<span class="ls-sf-check">✓</span></span>';
454
 
455
  root.appendChild(player); root.appendChild(wrap);
456
 
 
461
  els.time = player.querySelector('.ls-time');
462
  els.progress = player.querySelector('.ls-progress');
463
  els.fill = player.querySelector('.ls-fill');
464
+ els.sf = player.querySelector('.ls-sf');
465
 
466
  els.playBtn.addEventListener('click', play);
467
  els.pauseBtn.addEventListener('click', pause);
web/soundfont/gm.sf3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5b85b6c2c61d10b2b91cddd41efcce7b25cd31c8271d511c73afafbef20b6fa3
3
+ size 39900972
web/vendor/js-synthesizer.min.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:296f3cf76304b3650d7fb24a00263e2225e21d612fc0882234de7b1090da2b53
3
+ size 32516