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
+2
View File
@@ -21,6 +21,8 @@ RUN apt-get update && apt-get install -y \
libfluidsynth3 \ libfluidsynth3 \
libcurl4 \ libcurl4 \
build-essential \ build-essential \
fluidsynth \
vorbis-tools \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Thiết lập biến môi trường hiển thị cho X11 ảo # Thiết lập biến môi trường hiển thị cho X11 ảo
+23
View File
@@ -1,11 +1,13 @@
import os, uuid, json, tempfile import os, uuid, json, tempfile
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks
from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, Any from typing import Optional, Any
from app.config import settings from app.config import settings
from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH
from app.core.render_engine import PythonRenderEngine from app.core.render_engine import PythonRenderEngine
from app.core.soundfont_inspector import SoundFontInspector from app.core.soundfont_inspector import SoundFontInspector
from app.core.soundfont_converter import SoundFontConverter
from app.api.v1.auth import get_current_user from app.api.v1.auth import get_current_user
router = APIRouter() 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} 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): class RenderRequest(BaseModel):
project_json: dict project_json: dict
output_filename: Optional[str] = "render_output.wav" 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 import FastAPI
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles 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.ai_proxy import router as ai_proxy_router
from app.api.v1.plugins import router as plugins_router from app.api.v1.plugins import router as plugins_router
from app.core.auth import seed_admin from app.core.auth import seed_admin
from app.core.soundfont_converter import SoundFontConverter
# Ensure storage directories exist # Ensure storage directories exist
os.makedirs(settings.UPLOADS_DIR, exist_ok=True) 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(): async def startup_seed_admin():
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) @app.get("/", response_class=HTMLResponse)
async def get_index(): async def get_index():
index_path = os.path.join(settings.TEMPLATES_DIR, "index.html") 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) { if (window.SonicAudio && window.SonicAudio.initAudioWorklet) {
window.SonicAudio.initAudioWorklet(); window.SonicAudio.initAudioWorklet();
} }
if (window.SonicSF && window.SonicSF.init) {
window.SonicSF.init(audioCtx);
}
} }
if (audioCtx.state === 'suspended') { if (audioCtx.state === 'suspended') {
audioCtx.resume(); audioCtx.resume();
@@ -6519,6 +6522,11 @@ const App = () => {
setInstrumentSelectorTrackId(null); setInstrumentSelectorTrackId(null);
setSynthCategory(null); setSynthCategory(null);
setSelectedSoundFontId(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 => { setSubTabs(prev => prev.map(s => {
if (s.trackId !== trackId) return s; if (s.trackId !== trackId) return s;
return { ...s, instrumentProgram: programNumber !== undefined ? programNumber : undefined, instrumentName: displayName, instrumentId }; return { ...s, instrumentProgram: programNumber !== undefined ? programNumber : undefined, instrumentName: displayName, instrumentId };
@@ -13867,6 +13875,9 @@ const App = () => {
if (window.SonicSF && window.SonicSF.applyAITrackInstrument) { if (window.SonicSF && window.SonicSF.applyAITrackInstrument) {
window.SonicSF.applyAITrackInstrument(aiTrack.soundfont_bank, aiTrack.soundfont_program); 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; return updatedTracks;
+123 -22
View File
@@ -1,10 +1,8 @@
// SonicForge Studio SoundFont Player Service
(function () { (function () {
'use strict'; 'use strict';
const activeOscillators = {}; const activeOscillators = {};
// Use the shared AudioContext from the main app (lazy init)
let __gainNode = null; let __gainNode = null;
const getCtx = () => { const getCtx = () => {
if (typeof getAudioContext === 'function') { if (typeof getAudioContext === 'function') {
@@ -30,15 +28,70 @@
return window.__sharedAudioCtx; return window.__sharedAudioCtx;
}; };
// ── Per-channel MIDI state (16 GM channels) ──
const _channels = Array.from({ length: 16 }, () => ({ bank: 0, program: 0, isPercussion: false })); const _channels = Array.from({ length: 16 }, () => ({ bank: 0, program: 0, isPercussion: false }));
let _nextMelodicChannel = 0; let _nextMelodicChannel = 0;
// ── SpessaSynth integration state ──
let _synthInstance = null;
let _initialized = false;
let _currentSfId = null;
const SonicSF = { const SonicSF = {
loadedFonts: {}, 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) { controllerChange: function (channel, controller, value) {
if (channel < 0 || channel > 15) return; if (channel < 0 || channel > 15) return;
if (_initialized && _synthInstance) {
try { _synthInstance.controllerChange(channel, controller, value); } catch (e) {}
}
if (controller === 0) { if (controller === 0) {
_channels[channel].bank = value; _channels[channel].bank = value;
_channels[channel].isPercussion = (value === 128); _channels[channel].isPercussion = (value === 128);
@@ -47,6 +100,9 @@
programChange: function (channel, program) { programChange: function (channel, program) {
if (channel < 0 || channel > 15) return; if (channel < 0 || channel > 15) return;
if (_initialized && _synthInstance) {
try { _synthInstance.programChange(channel, program); } catch (e) {}
}
_channels[channel].program = program; _channels[channel].program = program;
}, },
@@ -65,6 +121,9 @@
const channel = this.allocateChannel(bank); const channel = this.allocateChannel(bank);
this.controllerChange(channel, 0, bank); this.controllerChange(channel, 0, bank);
this.programChange(channel, program); this.programChange(channel, program);
if (_initialized && synthEngine && synthEngine.soundfont_id) {
this.loadSoundFont(synthEngine.soundfont_id);
}
return channel; return channel;
}, },
@@ -73,8 +132,7 @@
return { ..._channels[channel] }; return { ..._channels[channel] };
}, },
// Load SoundFont from URL → ArrayBuffer → store in memory loadSoundFontLegacy: async function (url) {
loadSoundFont: async function (url) {
if (this.loadedFonts[url]) return this.loadedFonts[url]; if (this.loadedFonts[url]) return this.loadedFonts[url];
const resp = await fetch(url); const resp = await fetch(url);
if (!resp.ok) throw new Error('Failed to load SoundFont: ' + 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) { 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 ctx = getCtx();
const freq = 440 * Math.pow(2, (note - 69) / 12); const freq = 440 * Math.pow(2, (note - 69) / 12);
if (freq <= 0 || isNaN(freq)) return null; if (freq <= 0 || isNaN(freq)) return null;
// Apply synth_engine state if provided
if (synthEngine) { if (synthEngine) {
const ch = channel !== undefined ? channel : (synthEngine.soundfont_bank === 128 ? 9 : 0); const ch = channel !== undefined ? channel : (synthEngine.soundfont_bank === 128 ? 9 : 0);
this.controllerChange(ch, 0, synthEngine.soundfont_bank || 0); this.controllerChange(ch, 0, synthEngine.soundfont_bank || 0);
@@ -100,7 +199,6 @@
const osc = ctx.createOscillator(); const osc = ctx.createOscillator();
const noteGain = ctx.createGain(); const noteGain = ctx.createGain();
// Default settings
let oscType = 'triangle'; let oscType = 'triangle';
let attackTime = 0.03; let attackTime = 0.03;
let decayTime = 0.1; let decayTime = 0.1;
@@ -113,63 +211,63 @@
const chState = _channels[channel]; const chState = _channels[channel];
prog = chState.program || prog; prog = chState.program || prog;
} }
if (prog >= 0 && prog <= 7) { // Pianos if (prog >= 0 && prog <= 7) {
oscType = 'sine'; oscType = 'sine';
decayTime = 0.3; decayTime = 0.3;
sustainLevel = 0.1; sustainLevel = 0.1;
releaseTime = 0.2; releaseTime = 0.2;
} else if (prog >= 8 && prog <= 15) { // Chromatic Perc } else if (prog >= 8 && prog <= 15) {
oscType = 'sine'; oscType = 'sine';
decayTime = 0.1; decayTime = 0.1;
sustainLevel = 0.0; sustainLevel = 0.0;
releaseTime = 0.1; releaseTime = 0.1;
} else if (prog >= 16 && prog <= 23) { // Organs } else if (prog >= 16 && prog <= 23) {
oscType = 'sine'; oscType = 'sine';
attackTime = 0.05; attackTime = 0.05;
sustainLevel = 0.8; sustainLevel = 0.8;
releaseTime = 0.1; releaseTime = 0.1;
} else if (prog >= 24 && prog <= 31) { // Guitars } else if (prog >= 24 && prog <= 31) {
oscType = 'triangle'; oscType = 'triangle';
decayTime = 0.4; decayTime = 0.4;
sustainLevel = 0.2; sustainLevel = 0.2;
releaseTime = 0.3; releaseTime = 0.3;
} else if (prog >= 32 && prog <= 39) { // Basses } else if (prog >= 32 && prog <= 39) {
oscType = 'triangle'; oscType = 'triangle';
attackTime = 0.02; attackTime = 0.02;
decayTime = 0.2; decayTime = 0.2;
sustainLevel = 0.6; sustainLevel = 0.6;
releaseTime = 0.2; releaseTime = 0.2;
} else if (prog >= 40 && prog <= 47) { // Strings } else if (prog >= 40 && prog <= 47) {
oscType = 'sawtooth'; oscType = 'sawtooth';
attackTime = 0.15; attackTime = 0.15;
sustainLevel = 0.8; sustainLevel = 0.8;
releaseTime = 0.5; releaseTime = 0.5;
volFactor = 0.15; volFactor = 0.15;
} else if (prog >= 48 && prog <= 55) { // Ensemble / Choir } else if (prog >= 48 && prog <= 55) {
oscType = 'sawtooth'; oscType = 'sawtooth';
attackTime = 0.2; attackTime = 0.2;
sustainLevel = 0.8; sustainLevel = 0.8;
releaseTime = 0.6; releaseTime = 0.6;
volFactor = 0.12; volFactor = 0.12;
} else if (prog >= 56 && prog <= 63) { // Brass } else if (prog >= 56 && prog <= 63) {
oscType = 'sawtooth'; oscType = 'sawtooth';
attackTime = 0.08; attackTime = 0.08;
sustainLevel = 0.7; sustainLevel = 0.7;
releaseTime = 0.3; releaseTime = 0.3;
volFactor = 0.15; volFactor = 0.15;
} else if (prog >= 64 && prog <= 71) { // Reed } else if (prog >= 64 && prog <= 71) {
oscType = 'square'; oscType = 'square';
attackTime = 0.05; attackTime = 0.05;
sustainLevel = 0.6; sustainLevel = 0.6;
releaseTime = 0.2; releaseTime = 0.2;
volFactor = 0.15; volFactor = 0.15;
} else if (prog >= 72 && prog <= 79) { // Pipe } else if (prog >= 72 && prog <= 79) {
oscType = 'sine'; oscType = 'sine';
attackTime = 0.1; attackTime = 0.1;
sustainLevel = 0.7; sustainLevel = 0.7;
releaseTime = 0.3; releaseTime = 0.3;
volFactor = 0.2; volFactor = 0.2;
} else if (prog >= 80 && prog <= 119) { // Synth Lead/Pad/FX } else if (prog >= 80 && prog <= 119) {
oscType = 'sawtooth'; oscType = 'sawtooth';
attackTime = 0.05; attackTime = 0.05;
sustainLevel = 0.6; sustainLevel = 0.6;
@@ -186,7 +284,6 @@
const vel = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8; const vel = typeof velocity === 'number' ? (velocity > 1 ? velocity / 127 : velocity) : 0.8;
const targetGain = vel * volFactor; const targetGain = vel * volFactor;
// ADSR Envelope
noteGain.gain.setValueAtTime(0, startAt); noteGain.gain.setValueAtTime(0, startAt);
noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime); noteGain.gain.linearRampToValueAtTime(targetGain, startAt + attackTime);
noteGain.gain.linearRampToValueAtTime(targetGain * sustainLevel, startAt + attackTime + decayTime); noteGain.gain.linearRampToValueAtTime(targetGain * sustainLevel, startAt + attackTime + decayTime);
@@ -208,7 +305,6 @@
const oscId = `${note}_${Date.now()}_${Math.random()}`; const oscId = `${note}_${Date.now()}_${Math.random()}`;
activeOscillators[oscId] = { osc, gain: noteGain }; activeOscillators[oscId] = { osc, gain: noteGain };
// Clean up active oscillator reference after it stops
setTimeout(() => { setTimeout(() => {
delete activeOscillators[oscId]; delete activeOscillators[oscId];
}, (stopAt - ctx.currentTime) * 1000 + 100); }, (stopAt - ctx.currentTime) * 1000 + 100);
@@ -217,6 +313,13 @@
}, },
stopAll: function () { stopAll: function () {
if (_initialized && _synthInstance) {
try {
for (let ch = 0; ch < 16; ch++) {
_synthInstance.allNotesOff(ch);
}
} catch (e) {}
}
const ctx = getCtx(); const ctx = getCtx();
const now = ctx.currentTime; const now = ctx.currentTime;
Object.values(activeOscillators).forEach(entry => { Object.values(activeOscillators).forEach(entry => {
@@ -233,14 +336,12 @@
Object.keys(activeOscillators).forEach(k => delete activeOscillators[k]); Object.keys(activeOscillators).forEach(k => delete activeOscillators[k]);
}, },
// Save user SoundFont to IndexedDB via window.SonicStorage
saveToIndexedDB: async function (name, arrayBuffer) { saveToIndexedDB: async function (name, arrayBuffer) {
if (window.SonicStorage && window.SonicStorage.saveToIndexedDB) { if (window.SonicStorage && window.SonicStorage.saveToIndexedDB) {
await window.SonicStorage.saveToIndexedDB('soundfont_' + name, arrayBuffer); await window.SonicStorage.saveToIndexedDB('soundfont_' + name, arrayBuffer);
} }
}, },
// Load user SoundFont from IndexedDB
loadFromIndexedDB: async function (name) { loadFromIndexedDB: async function (name) {
if (window.SonicStorage && window.SonicStorage.loadFromIndexedDB) { if (window.SonicStorage && window.SonicStorage.loadFromIndexedDB) {
return await window.SonicStorage.loadFromIndexedDB('soundfont_' + name); 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/lucide@latest"></script>
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></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 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/api.js?v=202607232105"></script>
<script src="/static/js/services/audioEngine.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/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/soundfontPlayer.js?v=202607232105"></script>
<script src="/static/js/services/aiGateway.js?v=202607232105"></script> <script src="/static/js/services/aiGateway.js?v=202607232105"></script>
<script src="/static/js/services/dawCommandDispatcher.js?v=202607232105"></script> <script src="/static/js/services/dawCommandDispatcher.js?v=202607232105"></script>