diff --git a/app/static/js/services/soundfontPlayer.js b/app/static/js/services/soundfontPlayer.js index affd6be..0e024f3 100644 --- a/app/static/js/services/soundfontPlayer.js +++ b/app/static/js/services/soundfontPlayer.js @@ -33,19 +33,37 @@ let _synthInstance = null; let _initialized = false; - let _initInProgress = false; let _currentSfId = null; + let _bankLoadedCount = 0; + + // ── addSoundBank with timeout (SpessaSynth hangs on parse error) ── + async function _addBankWithTimeout(synth, buffer, sfId, timeoutMs) { + return new Promise((resolve) => { + const timer = setTimeout(() => { + console.warn("[SonicSF] addSoundBank timeout after", timeoutMs, "ms"); + resolve(false); + }, timeoutMs); + + synth.soundBankManager.addSoundBank(buffer.slice(0), sfId).then(() => { + clearTimeout(timer); + resolve(true); + }).catch((err) => { + clearTimeout(timer); + console.error("[SonicSF] addSoundBank rejected:", err); + resolve(false); + }); + }); + } const SonicSF = { loadedFonts: {}, + // ══════════════════════════════════════════════════ + // PHASE 1: Init SpessaSynth Engine (FlowClient.md 1.1) + // ══════════════════════════════════════════════════ init: async function (audioContext) { - if (_initialized && _synthInstance) return; - if (_initInProgress) return; - _initInProgress = true; if (!window.SpessaSynthClass || !window.__SpessaSynthCDN) { - console.warn("[SonicSF] SpessaSynth CDN not loaded. Using oscillator fallback."); - _initInProgress = false; + console.warn("[SonicSF] SpessaSynth CDN not available. Using oscillator."); return; } try { @@ -53,89 +71,59 @@ await audioContext.audioWorklet.addModule(procUrl); _synthInstance = new window.SpessaSynthClass(audioContext); await _synthInstance.isReady; - // Try loading default SGM bank to verify SpessaSynth works - try { - const resp = await fetch("/api/v1/plugins/soundfonts/download/sgm_v2.01?t=" + Date.now()); - if (resp.ok) { - const buf = await resp.arrayBuffer(); - await _synthInstance.soundBankManager.addSoundBank(buf.slice(0), "sgm_v2.01"); - await _synthInstance.isReady; - const bankCount = _synthInstance.soundBankManager?.soundBankList?.length ?? 0; - if (bankCount > 0) { - _initialized = true; - _initInProgress = false; - console.log("[SonicSF] SpessaSynth ready with", bankCount, "banks."); - return; - } - } - } catch (bankErr) { - console.warn("[SonicSF] Default bank load failed:", bankErr); - } - console.log("[SonicSF] SpessaSynth init OK but bank load failed. Using oscillator."); + _initialized = true; + console.log("[SonicSF] SpessaSynth engine ready."); } catch (e) { - console.warn("[SonicSF] AudioWorklet init failed:", e); + console.warn("[SonicSF] AudioWorklet failed:", e); try { const mod = await import("https://cdn.jsdelivr.net/npm/spessasynth_lib@4.3.1/dist/index.js"); _synthInstance = new mod.WorkerSynthesizer(audioContext); await _synthInstance.isReady; - console.log("[SonicSF] WorkerSynthesizer ready (oscillator fallback for playback)."); + _initialized = true; + console.log("[SonicSF] WorkerSynthesizer ready."); } catch (e2) { console.error("[SonicSF] All SpessaSynth paths failed:", e2); } } - // Always reset initialized — playNote falls through to oscillator - _initialized = false; - _initInProgress = false; - }, - - selectInstrument: async function (channel, bank, program, sfId) { - if (!_initialized || !_synthInstance) return; - if (sfId) { - const ok = await this.loadSoundFont(sfId); - if (!ok) { - console.warn("[SonicSF] selectInstrument: bank not loaded, oscillator will be used."); - return; - } - } - try { _synthInstance.controllerChange(channel, 0, bank); } catch (e) {} - try { _synthInstance.controllerChange(channel, 32, 0); } catch (e) {} - try { _synthInstance.programChange(channel, program); } catch (e) {} - this.controllerChange(channel, 0, bank); - this.programChange(channel, program); - console.log("[SonicSF] selectInstrument ch", channel, "bank", bank, "prog", program, "sf", sfId); }, + // ══════════════════════════════════════════════════ + // PHASE 2: Load SF3/SF2 → IndexedDB → SpessaSynth (FlowClient.md 2.) + // ══════════════════════════════════════════════════ loadSoundFont: async function (sfId) { - if (!_initialized || !_synthInstance) return; - if (_currentSfId === sfId) return; + if (!_initialized || !_synthInstance) return false; + if (_currentSfId === sfId) return true; console.log("[SonicSF] Loading SoundFont:", sfId); let buffer = null; if (window.SonicSFStorage) { buffer = await window.SonicSFStorage.getBuffer(sfId); + if (buffer) console.log("[SonicSF] Cache HIT for", sfId, buffer.byteLength, "bytes"); } if (!buffer) { try { - const resp = await fetch("/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now()); - if (!resp.ok) throw new Error("Download failed: " + resp.status); + const url = "/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId) + "?t=" + Date.now(); + const resp = await fetch(url); + if (!resp.ok) throw new Error("HTTP " + resp.status); buffer = await resp.arrayBuffer(); + console.log("[SonicSF] Downloaded", sfId, buffer.byteLength, "bytes"); if (window.SonicSFStorage) { await window.SonicSFStorage.saveBuffer(sfId, buffer); } } catch (e) { - console.error("[SonicSF] Failed to download:", sfId, e); + console.error("[SonicSF] Download failed:", sfId, e); return false; } } - try { - const bufCopy = buffer.slice(0); - await _synthInstance.soundBankManager.addSoundBank(bufCopy, sfId); - await _synthInstance.isReady; + // Load into SpessaSynth with 15s timeout + const ok = await _addBankWithTimeout(_synthInstance, buffer, sfId, 15000); + if (ok) { _currentSfId = sfId; - console.log("[SonicSF] SoundFont loaded:", sfId); + _bankLoadedCount = _synthInstance.soundBankManager?.soundBankList?.length ?? 0; + console.log("[SonicSF] SoundFont loaded:", sfId, "- banks:", _bankLoadedCount); return true; - } catch (e) { - console.error("[SonicSF] Error loading SF in SpessaSynth:", sfId, e); + } else { + console.warn("[SonicSF] SoundFont load FAILED:", sfId, "- using oscillator fallback"); if (window.SonicSFStorage) { try { const db = await window.SonicSFStorage.openDB(); @@ -148,6 +136,34 @@ } }, + // ══════════════════════════════════════════════════ + // PHASE 3: Set up MIDI Channel (FlowClient.md 3.) + // ══════════════════════════════════════════════════ + selectInstrument: async function (channel, bank, program, sfId) { + if (!_initialized || !_synthInstance) return; + // Phase 2: ensure SoundFont is loaded + if (sfId) { + const loaded = await this.loadSoundFont(sfId); + if (!loaded) { + console.warn("[SonicSF] selectInstrument: no SoundFont loaded, ch", channel); + return; + } + } + // Phase 3a: controllerChange(CC0) → bank MSB + try { _synthInstance.controllerChange(channel, 0, bank); } catch (e) {} + // Phase 3b: controllerChange(CC32) → bank LSB + try { _synthInstance.controllerChange(channel, 32, 0); } catch (e) {} + // Phase 3c: programChange + try { _synthInstance.programChange(channel, program); } catch (e) {} + // Phase 3d: update SonicSF internal state + this.controllerChange(channel, 0, bank); + this.programChange(channel, program); + console.log("[SonicSF] Switched Channel", channel, "-> Bank:", bank, ", Program:", program, ", SF:", sfId); + }, + + // ══════════════════════════════════════════════════ + // PHASE 4: Play MIDI Note (FlowClient.md 3.) + // ══════════════════════════════════════════════════ controllerChange: function (channel, controller, value) { if (channel < 0 || channel > 15) return; if (_initialized && _synthInstance) { @@ -190,44 +206,41 @@ }, playNote: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) { + // SpessaSynth path: only if bank is loaded if (_initialized && _synthInstance && _currentSfId) { - return this._playNoteSpessa(note, velocity, durationMs, startTime, program, channel, synthEngine); - } else { - if (_initialized && _synthInstance && !_currentSfId) { - console.warn("[SonicSF] No sound bank loaded, using oscillator fallback"); + if (synthEngine) { + const ch = channel !== undefined ? channel : (synthEngine.soundfont_bank === 128 ? 9 : 0); + try { _synthInstance.controllerChange(ch, 0, synthEngine.soundfont_bank || 0); } catch (e) {} + try { _synthInstance.controllerChange(ch, 32, 0); } catch (e) {} + try { _synthInstance.programChange(ch, synthEngine.soundfont_program || 0); } catch (e) {} + if (channel === undefined) channel = ch; } - return this._playNoteOsc(note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine); + if (channel === undefined) channel = 0; + this._playNoteSpessa(note, velocity, durationMs, channel); + return; } + // Oscillator fallback + this._playNoteOsc(note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine); }, - _playNoteSpessa: function (note, velocity, durationMs, startTime, program, channel, synthEngine) { + _playNoteSpessa: function (note, velocity, durationMs, channel) { const ctx = getCtx(); const midiPitch = Math.min(127, Math.max(0, parseInt(note) || 60)); - const midiVel = Math.min(127, Math.max(1, Math.floor((typeof velocity === 'number' ? (velocity > 1 ? velocity : velocity * 127) : 100)))); - - if (synthEngine) { - const ch = channel !== undefined ? channel : (synthEngine.soundfont_bank === 128 ? 9 : 0); - try { _synthInstance.controllerChange(ch, 0, synthEngine.soundfont_bank || 0); } catch (e) {} - try { _synthInstance.controllerChange(ch, 32, 0); } catch (e) {} - try { _synthInstance.programChange(ch, synthEngine.soundfont_program || 0); } catch (e) {} - if (channel === undefined) channel = ch; - } - if (channel === undefined) channel = 0; - + const midiVel = Math.min(127, Math.max(1, Math.floor( + typeof velocity === 'number' ? (velocity > 1 ? velocity : velocity * 127) : 100 + ))); const durSec = durationMs / 1000; - const now = ctx.currentTime; - const scheduledTime = (typeof startTime === 'number' && startTime > now) ? (startTime - now) : 0; - const doNoteOn = () => { try { _synthInstance.noteOn(channel, midiPitch, midiVel); - setTimeout(() => { try { _synthInstance.noteOff(channel, midiPitch); } catch (e) {} }, durSec * 1000); + setTimeout(() => { + try { _synthInstance.noteOff(channel, midiPitch); } catch (e) {} + }, durSec * 1000); } catch (e) { - console.warn("[SonicSF] SpessaSynth noteOn failed, falling back to oscillator:", e); - this._playNoteOsc(note, velocity, durationMs, startTime, program, null, channel, synthEngine); + console.warn("[SonicSF] noteOn failed:", e); } }; - if (scheduledTime > 0) setTimeout(doNoteOn, scheduledTime * 1000); else doNoteOn(); + doNoteOn(); }, _playNoteOsc: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) { @@ -245,13 +258,8 @@ const osc = ctx.createOscillator(); const noteGain = ctx.createGain(); - let oscType = 'triangle'; - let attackTime = 0.03; - let decayTime = 0.1; - let sustainLevel = 0.5; - let releaseTime = 0.2; - let volFactor = 0.25; + let attackTime = 0.03, decayTime = 0.1, sustainLevel = 0.5, releaseTime = 0.2, volFactor = 0.25; let prog = program !== undefined ? parseInt(program) : 0; if (channel !== undefined && channel >= 0 && channel < 16) { @@ -260,119 +268,74 @@ } if (prog >= 0 && prog <= 7) { oscType = 'sine'; - decayTime = 0.3; - sustainLevel = 0.1; - releaseTime = 0.2; + decayTime = 0.3; sustainLevel = 0.1; releaseTime = 0.2; } else if (prog >= 8 && prog <= 15) { oscType = 'sine'; - decayTime = 0.1; - sustainLevel = 0.0; - releaseTime = 0.1; + decayTime = 0.1; sustainLevel = 0.0; releaseTime = 0.1; } else if (prog >= 16 && prog <= 23) { oscType = 'sine'; - attackTime = 0.05; - sustainLevel = 0.8; - releaseTime = 0.1; + attackTime = 0.05; sustainLevel = 0.8; releaseTime = 0.1; } else if (prog >= 24 && prog <= 31) { oscType = 'triangle'; - decayTime = 0.4; - sustainLevel = 0.2; - releaseTime = 0.3; + decayTime = 0.4; sustainLevel = 0.2; releaseTime = 0.3; } else if (prog >= 32 && prog <= 39) { oscType = 'triangle'; - attackTime = 0.02; - decayTime = 0.2; - sustainLevel = 0.6; - releaseTime = 0.2; + attackTime = 0.02; decayTime = 0.2; sustainLevel = 0.6; releaseTime = 0.2; } else if (prog >= 40 && prog <= 47) { oscType = 'sawtooth'; - attackTime = 0.15; - sustainLevel = 0.8; - releaseTime = 0.5; - volFactor = 0.15; + attackTime = 0.15; sustainLevel = 0.8; releaseTime = 0.5; volFactor = 0.15; } else if (prog >= 48 && prog <= 55) { oscType = 'sawtooth'; - attackTime = 0.2; - sustainLevel = 0.8; - releaseTime = 0.6; - volFactor = 0.12; + attackTime = 0.2; sustainLevel = 0.8; releaseTime = 0.6; volFactor = 0.12; } else if (prog >= 56 && prog <= 63) { oscType = 'sawtooth'; - attackTime = 0.08; - sustainLevel = 0.7; - releaseTime = 0.3; - volFactor = 0.15; + attackTime = 0.08; sustainLevel = 0.7; releaseTime = 0.3; volFactor = 0.15; } else if (prog >= 64 && prog <= 71) { oscType = 'square'; - attackTime = 0.05; - sustainLevel = 0.6; - releaseTime = 0.2; - volFactor = 0.15; + attackTime = 0.05; sustainLevel = 0.6; releaseTime = 0.2; volFactor = 0.15; } else if (prog >= 72 && prog <= 79) { oscType = 'sine'; - attackTime = 0.1; - sustainLevel = 0.7; - releaseTime = 0.3; - volFactor = 0.2; + attackTime = 0.1; sustainLevel = 0.7; releaseTime = 0.3; volFactor = 0.2; } else if (prog >= 80 && prog <= 119) { oscType = 'sawtooth'; - attackTime = 0.05; - sustainLevel = 0.6; - releaseTime = 0.4; - volFactor = 0.15; + attackTime = 0.05; sustainLevel = 0.6; releaseTime = 0.4; volFactor = 0.15; } osc.type = oscType; osc.frequency.setValueAtTime(freq, 0); - const startAt = startTime !== undefined ? startTime : ctx.currentTime; const durSec = durationMs / 1000; - const vel = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8; const targetGain = vel * volFactor; noteGain.gain.setValueAtTime(0, startAt); noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime); noteGain.gain.linearRampToValueAtTime(targetGain * sustainLevel, startAt + attackTime + decayTime); - const releaseStart = startAt + Math.max(attackTime + decayTime, durSec); noteGain.gain.linearRampToValueAtTime(targetGain * sustainLevel, releaseStart); noteGain.gain.linearRampToValueAtTime(0, releaseStart + releaseTime); osc.connect(noteGain); - - const dest = destinationNode || __gainNode || ctx.destination; - noteGain.connect(dest); - + noteGain.connect(destinationNode || __gainNode || ctx.destination); osc.start(startAt); - const stopAt = releaseStart + releaseTime + 0.02; osc.stop(stopAt); const oscId = `${note}_${Date.now()}_${Math.random()}`; activeOscillators[oscId] = { osc, gain: noteGain }; - - setTimeout(() => { - delete activeOscillators[oscId]; - }, (stopAt - ctx.currentTime) * 1000 + 100); - + setTimeout(() => { delete activeOscillators[oscId]; }, (stopAt - ctx.currentTime) * 1000 + 100); return osc; }, stopAll: function () { if (_initialized && _synthInstance) { - try { - for (let ch = 0; ch < 16; ch++) _synthInstance.allNotesOff(ch); - } catch (e) {} + try { for (let ch = 0; ch < 16; ch++) _synthInstance.allNotesOff(ch); } catch (e) {} } const ctx = getCtx(); const now = ctx.currentTime; Object.values(activeOscillators).forEach(entry => { try { - if (entry.gain) { - entry.gain.gain.cancelScheduledValues(now); - entry.gain.gain.setValueAtTime(0, now); - } + if (entry.gain) { entry.gain.gain.cancelScheduledValues(now); entry.gain.gain.setValueAtTime(0, now); } if (entry.osc) try { entry.osc.stop(now); } catch (e) { } } catch (e) { } });