From e51b7fd3553eac6f48a786df42b3445e5d710f24 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sun, 26 Jul 2026 17:51:37 +0700 Subject: [PATCH] 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%) --- Dockerfile | 2 + app/api/v1/plugins.py | 23 +++ app/core/soundfont_converter.py | 191 +++++++++++++++++++++ app/main.py | 12 +- app/static/js/app.jsx | 11 ++ app/static/js/services/soundfontPlayer.js | 151 +++++++++++++--- app/static/js/services/soundfontStorage.js | 56 ++++++ app/templates/index.html | 6 + 8 files changed, 426 insertions(+), 26 deletions(-) create mode 100644 app/core/soundfont_converter.py create mode 100644 app/static/js/services/soundfontStorage.js diff --git a/Dockerfile b/Dockerfile index 06c0ccd..ddbea95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,8 @@ RUN apt-get update && apt-get install -y \ libfluidsynth3 \ libcurl4 \ build-essential \ + fluidsynth \ + vorbis-tools \ && rm -rf /var/lib/apt/lists/* # Thiết lập biến môi trường hiển thị cho X11 ảo diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index a01020d..a884d2d 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -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" diff --git a/app/core/soundfont_converter.py b/app/core/soundfont_converter.py new file mode 100644 index 0000000..05ca695 --- /dev/null +++ b/app/core/soundfont_converter.py @@ -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(" 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(" 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)) diff --git a/app/main.py b/app/main.py index bed5993..11dc1d3 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 721a90b..e8eadd6 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -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; diff --git a/app/static/js/services/soundfontPlayer.js b/app/static/js/services/soundfontPlayer.js index e3d0046..e39ce35 100644 --- a/app/static/js/services/soundfontPlayer.js +++ b/app/static/js/services/soundfontPlayer.js @@ -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); diff --git a/app/static/js/services/soundfontStorage.js b/app/static/js/services/soundfontStorage.js new file mode 100644 index 0000000..4fd6c06 --- /dev/null +++ b/app/static/js/services/soundfontStorage.js @@ -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(); +})(); diff --git a/app/templates/index.html b/app/templates/index.html index 57661a6..ffcc05f 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -10,9 +10,15 @@ + +