Spaces:
Running
Running
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');
})();
|