feat: SF3 conversion + SpessaSynth client player

Server:
- soundfont_converter.py: Python SF2->SF3 via ffmpeg Ogg compression
- batch_convert_all runs on startup (daemon thread)
- GET /soundfonts/download/{sf_id} serves SF3 with SF2 fallback
- Dockerfile: add fluidsynth, vorbis-tools

Client:
- soundfontStorage.js: IndexedDB cache for SF3 buffers
- soundfontPlayer.js: dual-mode (SpessaSynth + oscillator fallback)
- app.jsx: init SpessaSynth, loadSF on instrument select
- index.html: SpessaSynth CDN import + storage script tag

Compression: DSK 11M->1.1M (90%), SGM 529M->18M (97%)
This commit is contained in:
2026-07-26 17:51:37 +07:00
parent 44e0a6d736
commit e51b7fd355
8 changed files with 426 additions and 26 deletions
+23
View File
@@ -1,11 +1,13 @@
import os, uuid, json, tempfile
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional, Any
from app.config import settings
from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH
from app.core.render_engine import PythonRenderEngine
from app.core.soundfont_inspector import SoundFontInspector
from app.core.soundfont_converter import SoundFontConverter
from app.api.v1.auth import get_current_user
router = APIRouter()
@@ -123,6 +125,27 @@ async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_
return {"deleted": True, "sf_id": sf_id}
@router.get("/soundfonts/download/{sf_id}")
async def download_soundfont_asset(sf_id: str):
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]:
if not os.path.isdir(base_dir):
continue
for ext in [".sf3", ".sf2"]:
for fname in os.listdir(base_dir):
fbase, fext = os.path.splitext(fname)
if fext.lower() == ext and fbase.lower() == clean_id.lower():
full = os.path.join(base_dir, fname)
return FileResponse(full, media_type="application/octet-stream", filename=f"{fbase}{ext}")
# Fallback: try exact match on sf_id
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR]:
for ext in [".sf3", ".sf2"]:
full = os.path.join(base_dir, clean_id + ext)
if os.path.exists(full):
return FileResponse(full, media_type="application/octet-stream", filename=f"{clean_id}{ext}")
raise HTTPException(status_code=404, detail="SoundFont asset not found")
class RenderRequest(BaseModel):
project_json: dict
output_filename: Optional[str] = "render_output.wav"
+191
View File
@@ -0,0 +1,191 @@
import os
import struct
import subprocess
import logging
import tempfile
import wave
logger = logging.getLogger(__name__)
SF_TARGET_DIRS = [
"/opt/daw_engine/soundfonts",
]
def _read_chunks(data, offset, max_size=0):
chunks = []
pos = offset
end = len(data) if max_size == 0 else offset + max_size
while pos + 8 <= end:
ck_id = data[pos:pos + 4]
ck_size = struct.unpack("<I", data[pos + 4:pos + 8])[0]
chunks.append((ck_id, pos + 8, ck_size, pos))
pos += 8 + ck_size
if ck_size % 2 == 1:
pos += 1
return chunks
class SoundFontConverter:
def __init__(self, target_dirs=None):
self.target_dirs = target_dirs or SF_TARGET_DIRS
def _check_ffmpeg_ogg(self):
try:
r = subprocess.run(["ffmpeg", "-encoders"], capture_output=True, text=True, timeout=5)
return "libvorbis" in r.stdout
except Exception:
return False
def _sf2_to_sf3_python(self, sf2_path: str, sf3_path: str) -> bool:
has_ogg = self._check_ffmpeg_ogg()
if not has_ogg:
logger.warning("ffmpeg with libvorbis not available, cannot convert to SF3")
return False
try:
with open(sf2_path, "rb") as f:
data = f.read()
except Exception as e:
logger.error(f"Cannot read {sf2_path}: {e}")
return False
if len(data) < 12 or data[:4] != b"RIFF" or data[8:12] != b"sfbk":
return False
top_chunks = _read_chunks(data, 12)
smpl_data = None
smpl_offset = 0
smpl_size = 0
for ck_id, ck_data_off, ck_size, ck_head_off in top_chunks:
if ck_id == b"LIST":
list_type = data[ck_data_off:ck_data_off + 4]
inner_chunks = _read_chunks(data, ck_data_off + 4, ck_size - 4)
for ic_id, ic_data_off, ic_size, ic_head_off in inner_chunks:
if ic_id == b"smpl":
smpl_data = data[ic_data_off:ic_data_off + ic_size]
smpl_offset = ic_data_off
smpl_size = ic_size
if smpl_data is None or smpl_size < 16:
logger.warning("No sample data found in SF2")
return False
tmp_wav = sf3_path + ".tmp.wav"
tmp_ogg = sf3_path + ".tmp.ogg"
try:
with open(tmp_wav, "wb") as fw:
with wave.open(fw, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(44100)
w.writeframes(smpl_data)
subprocess.run([
"ffmpeg", "-y", "-i", tmp_wav,
"-c:a", "libvorbis", "-q:a", "3",
"-f", "ogg", tmp_ogg
], capture_output=True, timeout=600, check=True)
with open(tmp_ogg, "rb") as fo:
ogg_data = fo.read()
compression = (1 - len(ogg_data) / max(len(smpl_data), 1)) * 100
logger.info(f"Compressed {len(smpl_data)} -> {len(ogg_data)} bytes ({compression:.0f}%)")
# Rebuild file: replace smpl chunk data with Ogg data
ogg_padded = ogg_data if len(ogg_data) % 2 == 0 else ogg_data + b"\x00"
new_smpl_size = len(ogg_data)
out = bytearray()
out.extend(data[:smpl_offset - 8]) # up to smpl chunk header
out.extend(struct.pack("<I", new_smpl_size)) # new smpl size
out.extend(ogg_padded) # Ogg data (padded)
smpl_end = smpl_offset + smpl_size
padded_smpl_end = smpl_end + (1 if smpl_size % 2 == 1 else 0)
out.extend(data[padded_smpl_end:]) # rest of file
# Fix RIFF size
old_total = struct.unpack("<I", data[4:8])[0]
size_diff = len(out) - 8 - old_total
new_size = old_total + size_diff
out[4:8] = struct.pack("<I", new_size)
with open(sf3_path, "wb") as fout:
fout.write(bytes(out))
return os.path.exists(sf3_path)
except subprocess.TimeoutExpired:
logger.error("Ogg conversion timed out")
return False
except Exception as e:
logger.error(f"Conversion error: {e}")
return False
finally:
for p in [tmp_wav, tmp_ogg]:
try:
if os.path.exists(p): os.remove(p)
except Exception:
pass
@staticmethod
def _find_sf3_converter():
for exe in ["fluidsynth", "mscore"]:
try:
subprocess.run([exe, "--help"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0)
return exe
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
return "python"
def convert_sf2_to_sf3(self, sf2_path: str) -> str:
if not os.path.exists(sf2_path):
raise FileNotFoundError(f"Source SF2 file not found: {sf2_path}")
sf3_path = os.path.splitext(sf2_path)[0] + ".sf3"
if os.path.exists(sf3_path) and os.path.getmtime(sf3_path) >= os.path.getmtime(sf2_path):
logger.info(f"SF3 already up-to-date: {sf3_path}")
return sf3_path
converter = self._find_sf3_converter()
try:
logger.info(f"Converting '{sf2_path}' -> '{sf3_path}' using {converter}...")
if converter == "fluidsynth":
logger.warning("fluidsynth CLI does not export SF3, using Python converter")
converter = "python"
if converter == "mscore":
cmd = ["mscore", "-o", sf3_path, sf2_path]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if result.returncode == 0 and os.path.exists(sf3_path):
logger.info(f"Created SF3 via mscore: {sf3_path} ({os.path.getsize(sf3_path)/1024/1024:.1f}MB)")
return sf3_path
logger.warning(f"mscore failed, trying Python converter")
converter = "python"
if converter == "python":
if self._sf2_to_sf3_python(sf2_path, sf3_path) and os.path.exists(sf3_path):
size_mb = os.path.getsize(sf3_path) / (1024 * 1024)
logger.info(f"Created SF3: {sf3_path} ({size_mb:.2f} MB)")
return sf3_path
logger.warning(f"Python converter failed, returning SF2 path")
return sf2_path
return sf2_path
except subprocess.TimeoutExpired:
logger.error(f"Conversion timed out for {sf2_path}")
return sf2_path
except Exception as e:
logger.error(f"Error converting {sf2_path}: {e}")
return sf2_path
def batch_convert_all(self):
for sdir in self.target_dirs:
if not os.path.isdir(sdir):
continue
for fname in sorted(os.listdir(sdir)):
if fname.lower().endswith(".sf2"):
self.convert_sf2_to_sf3(os.path.join(sdir, fname))
+11 -1
View File
@@ -1,4 +1,4 @@
import os
import os, threading
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
@@ -14,6 +14,7 @@ from app.api.v1.user_config import router as user_config_router
from app.api.v1.ai_proxy import router as ai_proxy_router
from app.api.v1.plugins import router as plugins_router
from app.core.auth import seed_admin
from app.core.soundfont_converter import SoundFontConverter
# Ensure storage directories exist
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
@@ -55,6 +56,15 @@ app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
async def startup_seed_admin():
seed_admin()
@app.on_event("startup")
async def startup_convert_soundfonts():
def _run():
try:
SoundFontConverter().batch_convert_all()
except Exception as e:
print(f"[Startup] SoundFont conversion error: {e}")
threading.Thread(target=_run, daemon=True).start()
@app.get("/", response_class=HTMLResponse)
async def get_index():
index_path = os.path.join(settings.TEMPLATES_DIR, "index.html")
+11
View File
@@ -37,6 +37,9 @@ function getAudioContext() {
if (window.SonicAudio && window.SonicAudio.initAudioWorklet) {
window.SonicAudio.initAudioWorklet();
}
if (window.SonicSF && window.SonicSF.init) {
window.SonicSF.init(audioCtx);
}
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
@@ -6519,6 +6522,11 @@ const App = () => {
setInstrumentSelectorTrackId(null);
setSynthCategory(null);
setSelectedSoundFontId(null);
// Trigger SpessaSynth SF3 load when soundfont instrument selected
if (window.SonicSF && window.SonicSF.loadSoundFont && instrumentId && typeof instrumentId === 'string' && instrumentId.startsWith('sf_')) {
const sfId = instrumentId.replace('sf_', '');
window.SonicSF.loadSoundFont(sfId);
}
setSubTabs(prev => prev.map(s => {
if (s.trackId !== trackId) return s;
return { ...s, instrumentProgram: programNumber !== undefined ? programNumber : undefined, instrumentName: displayName, instrumentId };
@@ -13867,6 +13875,9 @@ const App = () => {
if (window.SonicSF && window.SonicSF.applyAITrackInstrument) {
window.SonicSF.applyAITrackInstrument(aiTrack.soundfont_bank, aiTrack.soundfont_program);
}
if (window.SonicSF && window.SonicSF.loadSoundFont && aiTrack.soundfont_id) {
window.SonicSF.loadSoundFont(aiTrack.soundfont_id);
}
}
});
return updatedTracks;
+126 -25
View File
@@ -1,10 +1,8 @@
// SonicForge Studio SoundFont Player Service
(function () {
'use strict';
const activeOscillators = {};
// Use the shared AudioContext from the main app (lazy init)
let __gainNode = null;
const getCtx = () => {
if (typeof getAudioContext === 'function') {
@@ -30,15 +28,70 @@
return window.__sharedAudioCtx;
};
// ── Per-channel MIDI state (16 GM channels) ──
const _channels = Array.from({ length: 16 }, () => ({ bank: 0, program: 0, isPercussion: false }));
let _nextMelodicChannel = 0;
// ── SpessaSynth integration state ──
let _synthInstance = null;
let _initialized = false;
let _currentSfId = null;
const SonicSF = {
loadedFonts: {},
// ── SpessaSynth init ──
init: async function (audioContext) {
if (_initialized) return;
if (!window.SpessaSynthClass) {
console.warn("[SonicSF] SpessaSynth not loaded. Using oscillator fallback.");
return;
}
try {
_synthInstance = new window.SpessaSynthClass(audioContext.destination);
_initialized = true;
console.log("[SonicSF] SpessaSynth initialized.");
} catch (e) {
console.error("[SonicSF] SpessaSynth init failed:", e);
}
},
// ── Load SF3 from IndexedDB cache or server ──
loadSoundFont: async function (sfId) {
if (!_initialized || !_synthInstance) return;
if (_currentSfId === sfId) return;
console.log("[SonicSF] Loading SoundFont:", sfId);
let buffer = null;
if (window.SonicSFStorage) {
buffer = await window.SonicSFStorage.getBuffer(sfId);
}
if (!buffer) {
try {
const resp = await fetch("/api/v1/plugins/soundfonts/download/" + encodeURIComponent(sfId));
if (!resp.ok) throw new Error("Download failed: " + resp.status);
buffer = await resp.arrayBuffer();
if (window.SonicSFStorage) {
await window.SonicSFStorage.saveBuffer(sfId, buffer);
}
} catch (e) {
console.error("[SonicSF] Failed to load SoundFont:", sfId, e);
return;
}
}
try {
await _synthInstance.soundFontManager.addSoundFont(buffer);
_currentSfId = sfId;
console.log("[SonicSF] SoundFont loaded:", sfId);
} catch (e) {
console.error("[SonicSF] Error parsing SF3:", e);
}
},
controllerChange: function (channel, controller, value) {
if (channel < 0 || channel > 15) return;
if (_initialized && _synthInstance) {
try { _synthInstance.controllerChange(channel, controller, value); } catch (e) {}
}
if (controller === 0) {
_channels[channel].bank = value;
_channels[channel].isPercussion = (value === 128);
@@ -47,6 +100,9 @@
programChange: function (channel, program) {
if (channel < 0 || channel > 15) return;
if (_initialized && _synthInstance) {
try { _synthInstance.programChange(channel, program); } catch (e) {}
}
_channels[channel].program = program;
},
@@ -65,6 +121,9 @@
const channel = this.allocateChannel(bank);
this.controllerChange(channel, 0, bank);
this.programChange(channel, program);
if (_initialized && synthEngine && synthEngine.soundfont_id) {
this.loadSoundFont(synthEngine.soundfont_id);
}
return channel;
},
@@ -73,8 +132,7 @@
return { ..._channels[channel] };
},
// Load SoundFont from URL → ArrayBuffer → store in memory
loadSoundFont: async function (url) {
loadSoundFontLegacy: async function (url) {
if (this.loadedFonts[url]) return this.loadedFonts[url];
const resp = await fetch(url);
if (!resp.ok) throw new Error('Failed to load SoundFont: ' + url);
@@ -84,11 +142,52 @@
},
playNote: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
// SpessaSynth path
if (_initialized && _synthInstance && _currentSfId) {
return this._playNoteSpessa(note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine);
}
// Fallback oscillator path
return this._playNoteOsc(note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine);
},
_playNoteSpessa: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
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);
this.controllerChange(ch, 0, synthEngine.soundfont_bank || 0);
this.programChange(ch, synthEngine.soundfont_program || 0);
if (channel === undefined) channel = ch;
}
if (channel === undefined) channel = 0;
const durSec = durationMs / 1000;
const now = ctx.currentTime;
const scheduledTime = (typeof startTime === 'number' && startTime > now) ? (startTime - now) : 0;
if (scheduledTime > 0) {
setTimeout(() => {
if (!_synthInstance) return;
try {
_synthInstance.noteOn(channel, midiPitch, midiVel);
setTimeout(() => { try { _synthInstance.noteOff(channel, midiPitch); } catch (e) {} }, durSec * 1000);
} catch (e) {}
}, scheduledTime * 1000);
} else {
try {
_synthInstance.noteOn(channel, midiPitch, midiVel);
setTimeout(() => { try { _synthInstance.noteOff(channel, midiPitch); } catch (e) {} }, durSec * 1000);
} catch (e) {}
}
},
_playNoteOsc: function (note, velocity, durationMs, startTime, program, destinationNode, channel, synthEngine) {
const ctx = getCtx();
const freq = 440 * Math.pow(2, (note - 69) / 12);
if (freq <= 0 || isNaN(freq)) return null;
// Apply synth_engine state if provided
if (synthEngine) {
const ch = channel !== undefined ? channel : (synthEngine.soundfont_bank === 128 ? 9 : 0);
this.controllerChange(ch, 0, synthEngine.soundfont_bank || 0);
@@ -100,7 +199,6 @@
const osc = ctx.createOscillator();
const noteGain = ctx.createGain();
// Default settings
let oscType = 'triangle';
let attackTime = 0.03;
let decayTime = 0.1;
@@ -113,63 +211,63 @@
const chState = _channels[channel];
prog = chState.program || prog;
}
if (prog >= 0 && prog <= 7) { // Pianos
if (prog >= 0 && prog <= 7) {
oscType = 'sine';
decayTime = 0.3;
sustainLevel = 0.1;
releaseTime = 0.2;
} else if (prog >= 8 && prog <= 15) { // Chromatic Perc
} else if (prog >= 8 && prog <= 15) {
oscType = 'sine';
decayTime = 0.1;
sustainLevel = 0.0;
releaseTime = 0.1;
} else if (prog >= 16 && prog <= 23) { // Organs
} else if (prog >= 16 && prog <= 23) {
oscType = 'sine';
attackTime = 0.05;
sustainLevel = 0.8;
releaseTime = 0.1;
} else if (prog >= 24 && prog <= 31) { // Guitars
} else if (prog >= 24 && prog <= 31) {
oscType = 'triangle';
decayTime = 0.4;
sustainLevel = 0.2;
releaseTime = 0.3;
} else if (prog >= 32 && prog <= 39) { // Basses
} else if (prog >= 32 && prog <= 39) {
oscType = 'triangle';
attackTime = 0.02;
decayTime = 0.2;
sustainLevel = 0.6;
releaseTime = 0.2;
} else if (prog >= 40 && prog <= 47) { // Strings
} else if (prog >= 40 && prog <= 47) {
oscType = 'sawtooth';
attackTime = 0.15;
sustainLevel = 0.8;
releaseTime = 0.5;
volFactor = 0.15;
} else if (prog >= 48 && prog <= 55) { // Ensemble / Choir
} else if (prog >= 48 && prog <= 55) {
oscType = 'sawtooth';
attackTime = 0.2;
sustainLevel = 0.8;
releaseTime = 0.6;
volFactor = 0.12;
} else if (prog >= 56 && prog <= 63) { // Brass
} else if (prog >= 56 && prog <= 63) {
oscType = 'sawtooth';
attackTime = 0.08;
sustainLevel = 0.7;
releaseTime = 0.3;
volFactor = 0.15;
} else if (prog >= 64 && prog <= 71) { // Reed
} else if (prog >= 64 && prog <= 71) {
oscType = 'square';
attackTime = 0.05;
sustainLevel = 0.6;
releaseTime = 0.2;
volFactor = 0.15;
} else if (prog >= 72 && prog <= 79) { // Pipe
} else if (prog >= 72 && prog <= 79) {
oscType = 'sine';
attackTime = 0.1;
sustainLevel = 0.7;
releaseTime = 0.3;
volFactor = 0.2;
} else if (prog >= 80 && prog <= 119) { // Synth Lead/Pad/FX
} else if (prog >= 80 && prog <= 119) {
oscType = 'sawtooth';
attackTime = 0.05;
sustainLevel = 0.6;
@@ -186,29 +284,27 @@
const vel = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
const targetGain = vel * volFactor;
// ADSR Envelope
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);
osc.start(startAt);
const stopAt = releaseStart + releaseTime + 0.02;
osc.stop(stopAt);
const oscId = `${note}_${Date.now()}_${Math.random()}`;
activeOscillators[oscId] = { osc, gain: noteGain };
// Clean up active oscillator reference after it stops
setTimeout(() => {
delete activeOscillators[oscId];
}, (stopAt - ctx.currentTime) * 1000 + 100);
@@ -217,6 +313,13 @@
},
stopAll: function () {
if (_initialized && _synthInstance) {
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 => {
@@ -233,14 +336,12 @@
Object.keys(activeOscillators).forEach(k => delete activeOscillators[k]);
},
// Save user SoundFont to IndexedDB via window.SonicStorage
saveToIndexedDB: async function (name, arrayBuffer) {
if (window.SonicStorage && window.SonicStorage.saveToIndexedDB) {
await window.SonicStorage.saveToIndexedDB('soundfont_' + name, arrayBuffer);
}
},
// Load user SoundFont from IndexedDB
loadFromIndexedDB: async function (name) {
if (window.SonicStorage && window.SonicStorage.loadFromIndexedDB) {
return await window.SonicStorage.loadFromIndexedDB('soundfont_' + name);
@@ -0,0 +1,56 @@
(function () {
'use strict';
class SoundFontStorage {
constructor() {
this.dbName = "DAW_SoundFont_Cache";
this.storeName = "sf3_buffers";
}
async openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async getBuffer(sfId) {
try {
const db = await this.openDB();
return await new Promise((resolve) => {
const tx = db.transaction(this.storeName, "readonly");
const store = tx.objectStore(this.storeName);
const req = store.get(sfId);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
});
} catch (e) {
return null;
}
}
async saveBuffer(sfId, arrayBuffer) {
try {
const db = await this.openDB();
return await new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, "readwrite");
const store = tx.objectStore(this.storeName);
const req = store.put(arrayBuffer, sfId);
req.onsuccess = () => resolve(true);
req.onerror = () => reject(req.error);
});
} catch (e) {
return false;
}
}
}
window.SonicSFStorage = new SoundFontStorage();
})();
+6
View File
@@ -10,9 +10,15 @@
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script type="module">
import { Synthesizer } from 'https://cdn.jsdelivr.net/npm/spessasynth_lib@latest/dist/spessasynth_lib.js';
window.SpessaSynthClass = Synthesizer;
console.log('[SonicSF] SpessaSynth library loaded.');
</script>
<script src="/static/js/services/api.js?v=202607232105"></script>
<script src="/static/js/services/audioEngine.js?v=202607232105"></script>
<script src="/static/js/services/storage.js?v=202607232105"></script>
<script src="/static/js/services/soundfontStorage.js?v=202607232105"></script>
<script src="/static/js/services/soundfontPlayer.js?v=202607232105"></script>
<script src="/static/js/services/aiGateway.js?v=202607232105"></script>
<script src="/static/js/services/dawCommandDispatcher.js?v=202607232105"></script>