T16: autosample VST2 via native bridge + SF2/SF3 export hoàn chỉnh, UI chip size/note_count, re-sample on preset change, tests

This commit is contained in:
2026-08-11 18:04:34 +07:00
parent 0af834a8fa
commit 371b1ca665
8 changed files with 184 additions and 50 deletions
+10 -4
View File
@@ -1144,17 +1144,22 @@ async def autosample_vsti(req: AutosampleRequest, current_user: dict = Depends(g
Tra sf_id (bare uuid) de client tai SF2 va phat qua SonicSF nhu soundfont
thuong. Chi phi autosample mot lan -> cache o client (localStorage)."""
enforce_password_changed(current_user)
if not HAS_PEDALBOARD:
raise HTTPException(status_code=501, detail="pedalboard khong kha dung tren may nay")
_dirs = _effective_dirs().get("plugin_dirs") or []
# tools/ nam ngoai package app - import voi fallback path
try:
from tools.autosample_vsti import autosample_sf2 as _autosample_sf2
from tools.autosample_vsti import autosample_sf2 as _autosample_sf2, _is_vst2_path
except ImportError:
_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if _root not in sys.path:
sys.path.insert(0, _root)
from tools.autosample_vsti import autosample_sf2 as _autosample_sf2
from tools.autosample_vsti import autosample_sf2 as _autosample_sf2, _is_vst2_path
if not HAS_PEDALBOARD:
# VST2 native bridge khong can pedalboard — chi chan khi plugin la VST3
_p = PluginManager(extra_vst_dirs=_dirs)._scan_plugins().get(req.instrument_id)
if not _p or not _is_vst2_path(_p):
raise HTTPException(status_code=501, detail="pedalboard khong kha dung tren may nay")
file_uuid = str(uuid.uuid4())
dest_path = os.path.join(UPLOAD_SF_DIR, file_uuid + ".sf2")
@@ -1174,6 +1179,7 @@ async def autosample_vsti(req: AutosampleRequest, current_user: dict = Depends(g
duration=req.duration, release=req.release,
velocity=req.velocity, sample_rate=req.sample_rate,
log=_log,
extra_vst_dirs=_dirs,
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
+5 -3
View File
@@ -445,8 +445,10 @@ class NativeAudioService:
def render_vst2_offline(self, plugin_path, notes, bpm=120.0, sr=44100,
gain_db=0.0, pan=0.0, lim_active=False,
threshold_db=-1.0, master_gain_db=0.0):
"""Offline VST2: SF_VST2_Process (raw) + mixer_math (Python mirror)."""
threshold_db=-1.0, master_gain_db=0.0, tail_sec=0.5):
"""Offline VST2: SF_VST2_Process (raw) + mixer_math (Python mirror).
tail_sec: duoi im lang sau note cuoi (autosample muon release tail)."""
with _LOCK:
dll = self._load_vst2_dll()
ctx = _Vst2Ctx(dll)
@@ -466,7 +468,7 @@ class NativeAudioService:
events.append((int((start_s + dur_s) * sr), "off", pitch, 0))
total = max(total, start_s + dur_s)
events.sort(key=lambda e: e[0])
n = int((max(total, 0.25) + 0.5) * sr)
n = int((max(total, 0.25) + float(tail_sec)) * sr)
out = np.zeros((2, n), dtype=np.float32)
block = 256
pos = 0
+35 -21
View File
@@ -5,6 +5,27 @@ import logging
import wave
logger = logging.getLogger(__name__)
def _ensure_fluidsynth_runtime() -> None:
"""Dua thu muc chua libfluidsynth DLL vao PATH de pyfluidsynth import duoc
(find_library do theo PATH tren Windows). Khong lam gi tren non-Windows."""
if os.name != "nt":
return
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
candidates = [
os.path.join(root, "native_host", "build", "Release", "fluidsynth_runtime"),
os.path.join(root, "native_host", "fluidsynth_runtime"),
]
path = os.environ.get("PATH", "")
parts = [os.path.normcase(p) for p in path.split(os.pathsep)]
for d in candidates:
if os.path.isdir(d) and any(
f.lower().startswith("libfluidsynth") and f.lower().endswith(".dll")
for f in os.listdir(d)
):
if os.path.normcase(d) not in parts:
os.environ["PATH"] = d + os.pathsep + path
return
SF_TARGET_DIRS = [
"/opt/daw_engine/soundfonts",
@@ -193,8 +214,8 @@ class SoundFontConverter:
# OGG loop pointers are relative to the individual decompressed sample
new_sloop = (startloop - start) if (startloop > start and startloop <= end) else 0
new_eloop = (endloop - start) if (endloop > start and endloop <= end) else 0
# Mark the sample as Ogg Vorbis compressed (FLUID_SAMPLETYPE_OGG_VORBIS = 0x20)
new_stype = sampletype | 0x20
# Mark the sample as Ogg Vorbis compressed (FLUID_SAMPLETYPE_OGG_VORBIS = 0x10)
new_stype = sampletype | 0x10
new_shdr += data[base:base + 20] # sample name
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_sloop, new_eloop, rate)
new_shdr += data[base + 40:base + 44] # originalpitch, correction, samplelink
@@ -282,35 +303,28 @@ class SoundFontConverter:
"""Verify a SoundFont actually loads and renders audible audio (guards
against shipping malformed SF3 files that silently play nothing).
Uses the low-level CFFI binding (new_fluid_synth / write_float) — the
high-level Synth() class does not exist in this binding, so it is never
used here.
Uses the high-level Synth() binding (sfload/get_samples).
"""
if not os.path.exists(path):
return False
_ensure_fluidsynth_runtime()
try:
import fluidsynth as _fs
import numpy as np
_settings = _fs.new_fluid_settings()
_fl = _fs.new_fluid_synth(_settings)
_synth = _fs.Synth()
try:
h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1)
if h < 0:
fid = _synth.sfload(path)
if fid == -1:
return False
_fs.fluid_synth_program_select(_fl, 0, h, 0, 0)
_fs.fluid_synth_noteon(_fl, 0, 60, 100)
frames = 8820 # 0.2s
buf = np.zeros(frames * 2, dtype=np.float32)
_fs.fluid_synth_write_float(
_fl, frames, buf.ctypes.data, 0, 1,
buf.ctypes.data + frames * 4, 0, 1
)
_fs.fluid_synth_noteoff(_fl, 0, 60)
rms = float(np.sqrt(np.mean(buf ** 2)))
_synth.program_select(0, fid, 0, 0)
_synth.noteon(0, 60, 100)
buf = _synth.get_samples(8820) # int16 stereo, 0.2s
_synth.noteoff(0, 60)
rms = float(np.sqrt(np.mean((buf.astype(np.float32) / 32768.0) ** 2)))
return rms > 1e-4
finally:
try:
_fs.delete_fluid_synth(_fl)
_synth.delete()
except Exception:
pass
except Exception:
@@ -453,7 +467,7 @@ class SoundFontConverter:
new_loopstart = loopstart + new_start if (loopstart or loopend) else 0
new_loopend = loopend + new_start if (loopstart or loopend) else 0
# Clear the Ogg Vorbis flag; keep mono/left/right/linked flags
new_stype = sampletype & ~0x20
new_stype = sampletype & ~0x10
new_shdr += name
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_loopstart, new_loopend, rate)
new_shdr += data[base + 40:base + 44]
+21 -2
View File
@@ -7823,6 +7823,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
React.useEffect(() => {
try { localStorage.setItem('sf_pr_zoom', String(rollZoom)); } catch (e) { }
}, [rollZoom]);
// Autosample VSTi xong re-render chip size/note_count (subtab header)
React.useEffect(function () {
const _h = () => setRenderTick(t => t + 1);
window.addEventListener('sf:autosample-update', _h);
return () => window.removeEventListener('sf:autosample-update', _h);
}, []);
const [aiBarStart, setAiBarStart] = React.useState(0);
const [aiBarEnd, setAiBarEnd] = React.useState(4);
@@ -9915,7 +9921,10 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
onClick: () => onInstrumentSelect && onInstrumentSelect(st.trackId),
title: st.instrumentName || "Synth",
className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 max-w-[70px] ${st.instrumentName ? 'bg-violet-900 text-violet-300 border-violet-700' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}`
}, React.createElement("i", { "data-lucide": "music", className: "w-3 h-3 shrink-0" }), React.createElement("span", { className: "truncate text-[9px]" }, activeParentTrackName ? ('(' + activeParentTrackName + ') ' + (st.instrumentName || 'Synth')) : (st.instrumentName || 'Synth'))), React.createElement("div", {
}, React.createElement("i", { "data-lucide": "music", className: "w-3 h-3 shrink-0" }), React.createElement("span", { className: "truncate text-[9px]" }, activeParentTrackName ? ('(' + activeParentTrackName + ') ' + (st.instrumentName || 'Synth')) : (st.instrumentName || 'Synth'))), (function() {
var _e = (typeof window.SonicVstiAutosample !== 'undefined') ? window.SonicVstiAutosample.entryFor(st.synth_engine) : null;
return _e ? /*#__PURE__*/React.createElement("span", { className: "px-1 py-0.5 bg-zinc-900 border border-zinc-800 text-emerald-400/90 rounded text-[9px] font-mono whitespace-nowrap shrink-0", title: "Autosampled: " + (_e.note_count || 0) + " notes" }, window.SonicVstiAutosample.formatSize(_e.size_bytes) + " \u00b7 " + (_e.note_count || 0) + "n") : null;
})(), React.createElement("div", {
className: "flex items-center gap-1 ml-1 text-xs"
}, React.createElement("span", { className: "text-zinc-500" }, "AI:"), React.createElement("input", {
type: "number", value: aiBarStart, onChange: e => setAiBarStart(parseInt(e.target.value) || 0),
@@ -15374,6 +15383,13 @@ const App = () => {
};
const [instrumentSelectorData, setInstrumentSelectorData] = useState(null);
const [instrumentRefreshKey, setInstrumentRefreshKey] = useState(0);
// Autosample VSTi xong re-render chip size/note_count (track strip)
const [autosampleVersion, setAutosampleVersion] = React.useState(0);
React.useEffect(() => {
const _h = () => setAutosampleVersion(v => v + 1);
window.addEventListener('sf:autosample-update', _h);
return () => window.removeEventListener('sf:autosample-update', _h);
}, []);
const openInstrumentSelector = trackId => {
setInstrumentSelectorTrackId(trackId);
setSfPresetSearchQuery('');
@@ -29708,7 +29724,10 @@ STRICT CONSTRAINTS:
}, /*#__PURE__*/React.createElement("i", {
"data-lucide": "music",
className: "w-3 h-3"
})), /*#__PURE__*/React.createElement("span", { className: "truncate text-[10px]" }, track.instrumentName || track.instrumentId || "Synth"), /*#__PURE__*/React.createElement("i", { "data-lucide": "chevron-down", className: "w-3 h-3 shrink-0" }))), /*#__PURE__*/React.createElement("div", {
})), /*#__PURE__*/React.createElement("span", { className: "truncate text-[10px]" }, track.instrumentName || track.instrumentId || "Synth"), /*#__PURE__*/React.createElement("i", { "data-lucide": "chevron-down", className: "w-3 h-3 shrink-0" }))), (function() {
var _e = (typeof window.SonicVstiAutosample !== 'undefined') ? window.SonicVstiAutosample.entryFor(track.synth_engine) : null;
return _e ? /*#__PURE__*/React.createElement("span", { className: "px-1.5 py-0.5 bg-zinc-900 border border-zinc-800 text-emerald-400/90 rounded text-[9px] font-mono whitespace-nowrap shrink-0", title: "Autosampled: " + (_e.note_count || 0) + " notes" }, window.SonicVstiAutosample.formatSize(_e.size_bytes) + " \u00b7 " + (_e.note_count || 0) + "n") : null;
})(), /*#__PURE__*/React.createElement("div", {
onMouseDown: e => handleTrackResizeMouseDown(e, track.id),
className: "absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors",
onClick: e => e.stopPropagation()
File diff suppressed because one or more lines are too long
+16
View File
@@ -27,6 +27,17 @@ window.SonicVstiAutosample = window.SonicVstiAutosample || {};
} catch (e) {}
}
function formatSize(bytes) {
if (!bytes) return '0 KB';
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
return Math.max(1, Math.round(bytes / 1024)) + ' KB';
}
// UI re-render (chip hien thi note_count/size) khi autosample xong/fail
function notify(k) {
try { window.dispatchEvent(new CustomEvent('sf:autosample-update', { detail: { key: k } })); } catch (e) {}
}
// Key = plugin + preset → autosample lại khi đổi preset (âm preset mới).
function keyFor(synthEngine) {
if (!synthEngine) return '';
@@ -65,6 +76,7 @@ window.SonicVstiAutosample = window.SonicVstiAutosample || {};
}
if (!window.SonicAPI || !window.SonicAPI.autosampleVsti) {
recordFail(k, 'API autosample khong kha dung');
notify(k);
return Promise.resolve(null);
}
_inFlight[k] = new Promise(function (resolve) {
@@ -80,14 +92,17 @@ window.SonicVstiAutosample = window.SonicVstiAutosample || {};
var map = readMap();
map[k] = { sf_id: res.sf_id, size_bytes: res.size_bytes || 0, note_count: res.note_count || 0, plugin_id: synthEngine.plugin_id, preset_id: synthEngine.preset_id || synthEngine.presetId || '', ts: Date.now() };
writeMap(map);
notify(k);
resolve(res.sf_id);
} else {
recordFail(k, (res && res.error) ? String(res.error) : 'Server khong tra sf_id');
notify(k);
resolve(null);
}
}).catch(function (err) {
delete _inFlight[k];
recordFail(k, (err && err.message) ? String(err.message) : 'Loi mang / server autosample');
notify(k);
resolve(null);
});
});
@@ -115,6 +130,7 @@ window.SonicVstiAutosample = window.SonicVstiAutosample || {};
keyFor: keyFor,
sfIdFor: sfIdFor,
entryFor: entryFor,
formatSize: formatSize,
ensure: ensure,
failReasonFor: failReasonFor,
clearCache: clearCache