File size: 10,921 Bytes
78f88e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a7be55
 
 
 
 
 
78f88e1
 
 
 
 
 
 
 
 
 
 
 
b48cc75
78f88e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3225df7
 
 
 
 
78f88e1
3225df7
78f88e1
 
3225df7
 
 
78f88e1
 
 
3225df7
 
 
2a7be55
 
 
 
 
 
b48cc75
2a7be55
 
 
 
 
 
 
 
 
 
 
 
b48cc75
 
 
2a7be55
 
 
78f88e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a7be55
 
 
 
 
 
 
78f88e1
 
 
 
b48cc75
78f88e1
b48cc75
 
 
 
 
 
 
 
78f88e1
 
 
 
 
2a7be55
 
 
 
 
 
3225df7
 
 
 
 
78f88e1
3225df7
 
78f88e1
3225df7
78f88e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b48cc75
 
 
 
78f88e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
/* LilyScript FluidSynth audio backend — vanilla-JS port of lilylet-live-editor's
 * src/lib/audio/fluidAudio.ts, exposed as window.LilyFluidAudio.
 *
 * Replaces the legacy single-piano MIDI.js soundfont with FluidSynth (js-synthesizer
 * WASM) playing a General MIDI gm.sf3 (128 instruments), so each track sounds with
 * its own program instead of everything-as-piano.
 *
 * Shaped to match the subset of music-widgets' MidiAudio API that score-player.js
 * drives: empty / loadPlugin / resume / noteOn / noteOff / programChange /
 * stopAllNotes. score-player.js sends noteOn/noteOff with an absolute
 * performance.now()-based timestamp; we forward to FluidSynth's Sequencer via
 * sendEventAt(event, dtMs, isAbsolute=false) ("play in dtMs ms"), preserving the
 * look-ahead timing without reconciling against the sequencer's own tick origin.
 *
 * Fallback: gm.sf3 is ~40 MB and takes a moment to fetch + decode. While it loads we
 * fall back to the legacy MIDI.js WebAudio piano (the small, locally-bundled
 * acoustic-grand-piano soundfont) so playback is audible immediately; once FluidSynth
 * is ready we switch to it transparently.
 *
 * Globals consumed (loaded via <script src> in <head> by app.py build_head):
 *   window.JSSynth                     js-synthesizer UMD { AudioWorkletNodeSynthesizer, ... }
 *   window.musicWidgetsBrowser.MidiAudio   legacy MIDI.js WebAudio piano (fallback)
 *   window.__LILYSCRIPT_FLUID_URL      base URL of web/fluid/ (libfluidsynth + worklet)
 *   window.__LILYSCRIPT_SOUNDFONT_URL  base URL of web/soundfont/ (gm.sf3 + piano)
 */
(function () {
	'use strict';

	function log (msg) { console.log('[LilyFluid]', msg); }

	// URLs (with trailing slash) injected by app.py; fall back to relative paths.
	var FLUID_URL = (window.__LILYSCRIPT_FLUID_URL || './fluid/');
	var SOUNDFONT_URL = (window.__LILYSCRIPT_SOUNDFONT_URL || './soundfont/');

	// DEBUG TOGGLE: when true, skip loading gm.sf3 / FluidSynth entirely and keep
	// playing through the legacy ogg.js piano fallback. Lets us isolate whether the
	// fallback path alone is audible, independent of the FluidSynth handoff. Set via
	// window.__LILYSCRIPT_DISABLE_FLUID before this script runs, or flip the default.
	var DISABLE_FLUID = !!window.__LILYSCRIPT_DISABLE_FLUID;

	// The with-libsndfile build is required to decode .sf3 (ogg-compressed) fonts;
	// it also reads .sf2. Both modules load into the AudioWorklet scope.
	var LIBFLUIDSYNTH_FILE = FLUID_URL + 'libfluidsynth-2.4.6-with-libsndfile.js';
	var WORKLET_FILE = FLUID_URL + 'js-synthesizer.worklet.js';
	var SOUNDFONT_FILE = SOUNDFONT_URL + 'gm.sf3';

	var audioCtx = null;
	var synth = null;
	var seq = null;
	var node = null;          // the synth's AudioNode (kept for diagnostics)
	var loaded = false;
	var loadingPromise = null;
	var failed = false;        // true if the last gm.sf3 load attempt errored (network etc.)

	// Legacy MIDI.js fallback (single-piano), used until FluidSynth finishes loading.
	var legacy = null;
	var legacyReady = false;

	// Inject the legacy UMD MidiAudio (fallback). Must be called before loadPlugin().
	function init (legacyMidiAudio) {
		legacy = legacyMidiAudio || null;
	}

	// True once the (preferred) FluidSynth backend has finished loading.
	function ready () { return loaded; }

	// True while FluidSynth is still loading (the fallback may already be audible).
	function loading () { return !loaded && !!loadingPromise; }

	// Whether any backend can produce sound yet (FluidSynth or the legacy fallback).
	function empty () { return !loaded && !legacyReady; }

	function delayFromNow (timestamp) { return Math.max(0, timestamp - performance.now()); }

	function loadPlugin () {
		if (loaded) return Promise.resolve();
		if (loadingPromise) return loadingPromise;

		// 1) Load the lightweight legacy piano fallback FIRST and prioritise it, so
		// playback is audible as soon as possible. The large GM soundfont (gm.sf3,
		// ~40MB) only starts downloading once the fallback is ready — we don't want
		// the 40MB fetch competing for bandwidth with the small fallback font.
		var legacyPromise;
		if (legacy && legacy.WebAudio && legacy.WebAudio.empty && legacy.WebAudio.empty()) {
			legacyPromise = legacy.loadPlugin({ soundfontUrl: SOUNDFONT_URL, api: 'webaudio' })
				.then(function () { legacyReady = true; log('legacy piano fallback ready'); })
				.catch(function (err) { console.warn('[LilyFluid] legacy fallback failed:', err); });
		} else {
			if (legacy) legacyReady = true;
			legacyPromise = Promise.resolve();
		}

		loadingPromise = (async function () {
			// 2) Wait for the fallback to finish before kicking off the heavy GM load.
			try { await legacyPromise; } catch (e) {}

			// DEBUG: gm.sf3/FluidSynth disabled — stay on the legacy ogg.js fallback.
			if (DISABLE_FLUID) {
				log('FluidSynth DISABLED (DISABLE_FLUID) — using legacy ogg.js fallback only');
				return;
			}

			try {
			// 3) Fetch the 40MB gm.sf3 bytes FIRST, before touching any AudioContext.
			// This is the key to keeping the fallback audible the whole time: if we
			// instead created FluidSynth's AudioContext (+ worklet modules) up-front and
			// only then downloaded the soundfont, TWO suspended AudioContexts (the legacy
			// fallback's and FluidSynth's) would coexist during the long download window.
			// The browser autoplay policy only lets us resume a suspended context
			// synchronously inside a user gesture, and with two of them the single
			// in-gesture resume() couldn't reliably warm BOTH — the legacy one lost and
			// stayed silent (the "fallback plays but no sound until gm.sf3 loads" bug).
			// By deferring all FluidSynth context creation until the bytes are in hand,
			// exactly ONE AudioContext (the legacy fallback) is alive for the whole
			// download, so its in-gesture resume is unambiguous and it actually sounds.
			var sfResp = await fetch(SOUNDFONT_FILE);
			if (!sfResp.ok) throw new Error('gm.sf3 HTTP ' + sfResp.status);
			var sfontBuffer = await sfResp.arrayBuffer();
			log('gm.sf3 fetched (' + sfontBuffer.byteLength + ' bytes); building FluidSynth');

			// Now build the FluidSynth AudioContext + worklet graph and load the font.
			audioCtx = new (window.AudioContext || window.webkitAudioContext)();
			// libfluidsynth must be added before the worklet module.
			await audioCtx.audioWorklet.addModule(LIBFLUIDSYNTH_FILE);
			await audioCtx.audioWorklet.addModule(WORKLET_FILE);

			synth = new window.JSSynth.AudioWorkletNodeSynthesizer();
			synth.init(audioCtx.sampleRate);
			// createAudioNode MUST be called before any other synth method.
			node = synth.createAudioNode(audioCtx);
			node.connect(audioCtx.destination);

			await synth.loadSFont(sfontBuffer);

			seq = await synth.createSequencer();
			await seq.registerSynthesizer(synth);
			seq.setTimeScale(1000);   // 1 tick = 1 ms, matching performance.now()

			// FluidSynth's context is created fresh here, AFTER the user has likely
			// already pressed play (gm.sf3 takes seconds to fetch+decode). Its context
			// therefore starts suspended with no pending user gesture to resume it. Try
			// to resume right away; if the autoplay policy blocks it, the next play()
			// click resumes it synchronously in-gesture and the handoff completes then.
			if (audioCtx.state === 'suspended') { try { await audioCtx.resume(); } catch (e) {} }

			// Hand off from the fallback: silence any lingering notes.
			if (legacyReady && legacy && legacy.stopAllNotes) legacy.stopAllNotes();

			loaded = true;
			failed = false;
			log('FluidSynth soundfont loaded (gm.sf3)');
			} catch (err) {
				// gm.sf3 / FluidSynth load failed (network etc.). Flag it and clear the
				// in-flight promise so retryLoad() can start a fresh attempt. The legacy
				// piano fallback (if it loaded) stays audible regardless.
				failed = true;
				loadingPromise = null;
				console.warn('[LilyFluid] gm.sf3 / FluidSynth load failed:', err);
			}
		})();
		return loadingPromise;
	}

	// Resume audio output; must be called from a user gesture (autoplay policy).
	// A suspended AudioContext can only be resumed *synchronously within a user
	// gesture*, so we fire all resume()/awaitWarmup() calls BEFORE the first await.
	// During the fallback window `audioCtx` is null (FluidSynth's context isn't
	// created until gm.sf3 is fetched — see loadPlugin), so only the legacy WebAudio
	// context exists and gets warmed up here; there's no second context to contend
	// with. Once gm.sf3 has loaded, audioCtx exists and is resumed too.
	function resume () {
		var promises = [];
		if (audioCtx && audioCtx.state === 'suspended') {
			try { promises.push(audioCtx.resume()); } catch (e) {}
		}
		var WA = legacy && legacy.WebAudio;
		if (legacyReady && WA && WA.awaitWarmup) {
			try { promises.push(WA.awaitWarmup()); } catch (e) {}
		}
		return Promise.all(promises).catch(function () {});
	}

	function noteOn (channel, note, velocity, timestamp) {
		if (seq) { seq.sendEventAt({ type: 'noteon', channel: channel, key: note, vel: velocity }, delayFromNow(timestamp), false); return; }
		if (legacyReady && legacy) legacy.noteOn(channel, note, velocity, timestamp);
	}

	function noteOff (channel, note, timestamp) {
		if (seq) { seq.sendEventAt({ type: 'noteoff', channel: channel, key: note }, delayFromNow(timestamp), false); return; }
		if (legacyReady && legacy) legacy.noteOff(channel, note, timestamp);
	}

	function programChange (channel, program) {
		if (seq) { seq.sendEventAt({ type: 'programchange', channel: channel, preset: program }, 0, false); return; }
		if (legacyReady && legacy) legacy.programChange(channel, program);
	}

	function stopAllNotes () {
		if (seq) seq.removeAllEvents();   // drop the in-flight look-ahead window
		if (synth) { for (var ch = 0; ch < 16; ++ch) synth.midiAllSoundsOff(ch); }
		if (legacyReady && legacy && legacy.stopAllNotes) legacy.stopAllNotes();
	}

	window.LilyFluidAudio = {
		init: init,
		empty: empty,
		ready: ready,
		loading: loading,
		failed: function () { return failed; },
		// Retry a previously-failed gm.sf3 load. Clears the failed flag and re-runs
		// loadPlugin (loadingPromise was reset to null in the catch, so this starts fresh).
		retryLoad: function () { failed = false; return loadPlugin(); },
		loadPlugin: loadPlugin,
		resume: resume,
		noteOn: noteOn,
		noteOff: noteOff,
		programChange: programChange,
		stopAllNotes: stopAllNotes,
		// Lightweight diagnostics: backend/AudioContext state. (A scheduled note with
		// no thrown error does NOT prove audio output — for that, tap node with an
		// AnalyserNode in the console.) Handy when troubleshooting "no sound".
		_debug: function () {
			return {
				loaded: loaded, legacyReady: legacyReady,
				ctxState: audioCtx ? audioCtx.state : 'no-ctx',
				hasSeq: !!seq, hasSynth: !!synth, hasNode: !!node,
			};
		},
	};
	log('fluid-audio.js loaded');
})();