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:
+10
-4
@@ -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
|
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)."""
|
thuong. Chi phi autosample mot lan -> cache o client (localStorage)."""
|
||||||
enforce_password_changed(current_user)
|
enforce_password_changed(current_user)
|
||||||
if not HAS_PEDALBOARD:
|
_dirs = _effective_dirs().get("plugin_dirs") or []
|
||||||
raise HTTPException(status_code=501, detail="pedalboard khong kha dung tren may nay")
|
|
||||||
|
|
||||||
# tools/ nam ngoai package app - import voi fallback path
|
# tools/ nam ngoai package app - import voi fallback path
|
||||||
try:
|
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:
|
except ImportError:
|
||||||
_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
if _root not in sys.path:
|
if _root not in sys.path:
|
||||||
sys.path.insert(0, _root)
|
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())
|
file_uuid = str(uuid.uuid4())
|
||||||
dest_path = os.path.join(UPLOAD_SF_DIR, file_uuid + ".sf2")
|
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,
|
duration=req.duration, release=req.release,
|
||||||
velocity=req.velocity, sample_rate=req.sample_rate,
|
velocity=req.velocity, sample_rate=req.sample_rate,
|
||||||
log=_log,
|
log=_log,
|
||||||
|
extra_vst_dirs=_dirs,
|
||||||
)
|
)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|||||||
@@ -445,8 +445,10 @@ class NativeAudioService:
|
|||||||
|
|
||||||
def render_vst2_offline(self, plugin_path, notes, bpm=120.0, sr=44100,
|
def render_vst2_offline(self, plugin_path, notes, bpm=120.0, sr=44100,
|
||||||
gain_db=0.0, pan=0.0, lim_active=False,
|
gain_db=0.0, pan=0.0, lim_active=False,
|
||||||
threshold_db=-1.0, master_gain_db=0.0):
|
threshold_db=-1.0, master_gain_db=0.0, tail_sec=0.5):
|
||||||
"""Offline VST2: SF_VST2_Process (raw) + mixer_math (Python mirror)."""
|
"""Offline VST2: SF_VST2_Process (raw) + mixer_math (Python mirror).
|
||||||
|
|
||||||
|
tail_sec: duoi im lang sau note cuoi (autosample muon release tail)."""
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
dll = self._load_vst2_dll()
|
dll = self._load_vst2_dll()
|
||||||
ctx = _Vst2Ctx(dll)
|
ctx = _Vst2Ctx(dll)
|
||||||
@@ -466,7 +468,7 @@ class NativeAudioService:
|
|||||||
events.append((int((start_s + dur_s) * sr), "off", pitch, 0))
|
events.append((int((start_s + dur_s) * sr), "off", pitch, 0))
|
||||||
total = max(total, start_s + dur_s)
|
total = max(total, start_s + dur_s)
|
||||||
events.sort(key=lambda e: e[0])
|
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)
|
out = np.zeros((2, n), dtype=np.float32)
|
||||||
block = 256
|
block = 256
|
||||||
pos = 0
|
pos = 0
|
||||||
|
|||||||
@@ -5,6 +5,27 @@ import logging
|
|||||||
import wave
|
import wave
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 = [
|
SF_TARGET_DIRS = [
|
||||||
"/opt/daw_engine/soundfonts",
|
"/opt/daw_engine/soundfonts",
|
||||||
@@ -193,8 +214,8 @@ class SoundFontConverter:
|
|||||||
# OGG loop pointers are relative to the individual decompressed sample
|
# OGG loop pointers are relative to the individual decompressed sample
|
||||||
new_sloop = (startloop - start) if (startloop > start and startloop <= end) else 0
|
new_sloop = (startloop - start) if (startloop > start and startloop <= end) else 0
|
||||||
new_eloop = (endloop - start) if (endloop > start and endloop <= 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)
|
# Mark the sample as Ogg Vorbis compressed (FLUID_SAMPLETYPE_OGG_VORBIS = 0x10)
|
||||||
new_stype = sampletype | 0x20
|
new_stype = sampletype | 0x10
|
||||||
new_shdr += data[base:base + 20] # sample name
|
new_shdr += data[base:base + 20] # sample name
|
||||||
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_sloop, new_eloop, rate)
|
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_sloop, new_eloop, rate)
|
||||||
new_shdr += data[base + 40:base + 44] # originalpitch, correction, samplelink
|
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
|
"""Verify a SoundFont actually loads and renders audible audio (guards
|
||||||
against shipping malformed SF3 files that silently play nothing).
|
against shipping malformed SF3 files that silently play nothing).
|
||||||
|
|
||||||
Uses the low-level CFFI binding (new_fluid_synth / write_float) — the
|
Uses the high-level Synth() binding (sfload/get_samples).
|
||||||
high-level Synth() class does not exist in this binding, so it is never
|
|
||||||
used here.
|
|
||||||
"""
|
"""
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return False
|
return False
|
||||||
|
_ensure_fluidsynth_runtime()
|
||||||
try:
|
try:
|
||||||
import fluidsynth as _fs
|
import fluidsynth as _fs
|
||||||
import numpy as np
|
import numpy as np
|
||||||
_settings = _fs.new_fluid_settings()
|
_synth = _fs.Synth()
|
||||||
_fl = _fs.new_fluid_synth(_settings)
|
|
||||||
try:
|
try:
|
||||||
h = _fs.fluid_synth_sfload(_fl, path.encode("utf-8"), 1)
|
fid = _synth.sfload(path)
|
||||||
if h < 0:
|
if fid == -1:
|
||||||
return False
|
return False
|
||||||
_fs.fluid_synth_program_select(_fl, 0, h, 0, 0)
|
_synth.program_select(0, fid, 0, 0)
|
||||||
_fs.fluid_synth_noteon(_fl, 0, 60, 100)
|
_synth.noteon(0, 60, 100)
|
||||||
frames = 8820 # 0.2s
|
buf = _synth.get_samples(8820) # int16 stereo, 0.2s
|
||||||
buf = np.zeros(frames * 2, dtype=np.float32)
|
_synth.noteoff(0, 60)
|
||||||
_fs.fluid_synth_write_float(
|
rms = float(np.sqrt(np.mean((buf.astype(np.float32) / 32768.0) ** 2)))
|
||||||
_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)))
|
|
||||||
return rms > 1e-4
|
return rms > 1e-4
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
_fs.delete_fluid_synth(_fl)
|
_synth.delete()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -453,7 +467,7 @@ class SoundFontConverter:
|
|||||||
new_loopstart = loopstart + new_start if (loopstart or loopend) else 0
|
new_loopstart = loopstart + new_start if (loopstart or loopend) else 0
|
||||||
new_loopend = loopend + 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
|
# Clear the Ogg Vorbis flag; keep mono/left/right/linked flags
|
||||||
new_stype = sampletype & ~0x20
|
new_stype = sampletype & ~0x10
|
||||||
new_shdr += name
|
new_shdr += name
|
||||||
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_loopstart, new_loopend, rate)
|
new_shdr += struct.pack("<IIIIi", new_start, new_end, new_loopstart, new_loopend, rate)
|
||||||
new_shdr += data[base + 40:base + 44]
|
new_shdr += data[base + 40:base + 44]
|
||||||
|
|||||||
+21
-2
@@ -7823,6 +7823,12 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, activeTracks, onClos
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
try { localStorage.setItem('sf_pr_zoom', String(rollZoom)); } catch (e) { }
|
try { localStorage.setItem('sf_pr_zoom', String(rollZoom)); } catch (e) { }
|
||||||
}, [rollZoom]);
|
}, [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 [aiBarStart, setAiBarStart] = React.useState(0);
|
||||||
const [aiBarEnd, setAiBarEnd] = React.useState(4);
|
const [aiBarEnd, setAiBarEnd] = React.useState(4);
|
||||||
|
|
||||||
@@ -9915,7 +9921,10 @@ const beatSec = 60.0 / (parseInt(bpm) || 120);
|
|||||||
onClick: () => onInstrumentSelect && onInstrumentSelect(st.trackId),
|
onClick: () => onInstrumentSelect && onInstrumentSelect(st.trackId),
|
||||||
title: st.instrumentName || "Synth",
|
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'}`
|
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"
|
className: "flex items-center gap-1 ml-1 text-xs"
|
||||||
}, React.createElement("span", { className: "text-zinc-500" }, "AI:"), React.createElement("input", {
|
}, React.createElement("span", { className: "text-zinc-500" }, "AI:"), React.createElement("input", {
|
||||||
type: "number", value: aiBarStart, onChange: e => setAiBarStart(parseInt(e.target.value) || 0),
|
type: "number", value: aiBarStart, onChange: e => setAiBarStart(parseInt(e.target.value) || 0),
|
||||||
@@ -15374,6 +15383,13 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
const [instrumentSelectorData, setInstrumentSelectorData] = useState(null);
|
const [instrumentSelectorData, setInstrumentSelectorData] = useState(null);
|
||||||
const [instrumentRefreshKey, setInstrumentRefreshKey] = useState(0);
|
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 => {
|
const openInstrumentSelector = trackId => {
|
||||||
setInstrumentSelectorTrackId(trackId);
|
setInstrumentSelectorTrackId(trackId);
|
||||||
setSfPresetSearchQuery('');
|
setSfPresetSearchQuery('');
|
||||||
@@ -29708,7 +29724,10 @@ STRICT CONSTRAINTS:
|
|||||||
}, /*#__PURE__*/React.createElement("i", {
|
}, /*#__PURE__*/React.createElement("i", {
|
||||||
"data-lucide": "music",
|
"data-lucide": "music",
|
||||||
className: "w-3 h-3"
|
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),
|
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",
|
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()
|
onClick: e => e.stopPropagation()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -27,6 +27,17 @@ window.SonicVstiAutosample = window.SonicVstiAutosample || {};
|
|||||||
} catch (e) {}
|
} 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).
|
// Key = plugin + preset → autosample lại khi đổi preset (âm preset mới).
|
||||||
function keyFor(synthEngine) {
|
function keyFor(synthEngine) {
|
||||||
if (!synthEngine) return '';
|
if (!synthEngine) return '';
|
||||||
@@ -65,6 +76,7 @@ window.SonicVstiAutosample = window.SonicVstiAutosample || {};
|
|||||||
}
|
}
|
||||||
if (!window.SonicAPI || !window.SonicAPI.autosampleVsti) {
|
if (!window.SonicAPI || !window.SonicAPI.autosampleVsti) {
|
||||||
recordFail(k, 'API autosample khong kha dung');
|
recordFail(k, 'API autosample khong kha dung');
|
||||||
|
notify(k);
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
_inFlight[k] = new Promise(function (resolve) {
|
_inFlight[k] = new Promise(function (resolve) {
|
||||||
@@ -80,14 +92,17 @@ window.SonicVstiAutosample = window.SonicVstiAutosample || {};
|
|||||||
var map = readMap();
|
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() };
|
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);
|
writeMap(map);
|
||||||
|
notify(k);
|
||||||
resolve(res.sf_id);
|
resolve(res.sf_id);
|
||||||
} else {
|
} else {
|
||||||
recordFail(k, (res && res.error) ? String(res.error) : 'Server khong tra sf_id');
|
recordFail(k, (res && res.error) ? String(res.error) : 'Server khong tra sf_id');
|
||||||
|
notify(k);
|
||||||
resolve(null);
|
resolve(null);
|
||||||
}
|
}
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
delete _inFlight[k];
|
delete _inFlight[k];
|
||||||
recordFail(k, (err && err.message) ? String(err.message) : 'Loi mang / server autosample');
|
recordFail(k, (err && err.message) ? String(err.message) : 'Loi mang / server autosample');
|
||||||
|
notify(k);
|
||||||
resolve(null);
|
resolve(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -115,6 +130,7 @@ window.SonicVstiAutosample = window.SonicVstiAutosample || {};
|
|||||||
keyFor: keyFor,
|
keyFor: keyFor,
|
||||||
sfIdFor: sfIdFor,
|
sfIdFor: sfIdFor,
|
||||||
entryFor: entryFor,
|
entryFor: entryFor,
|
||||||
|
formatSize: formatSize,
|
||||||
ensure: ensure,
|
ensure: ensure,
|
||||||
failReasonFor: failReasonFor,
|
failReasonFor: failReasonFor,
|
||||||
clearCache: clearCache
|
clearCache: clearCache
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
"""Tests cho tools/autosample_vsti.py — SF2 writer tự-sinh phải là RIFF/sfbk
|
"""Tests cho tools/autosample_vsti.py — SF2 writer tự-sinh phải là RIFF/sfbk
|
||||||
hợp lệ mà sf2utils parse được (không cần VSTi/pedalboard)."""
|
hợp lệ mà sf2utils parse được (không cần VSTi/pedalboard)."""
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tools.autosample_vsti import write_sf2
|
from tools.autosample_vsti import write_sf2
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
FAKE_VST2 = os.path.join(ROOT, "native_host", "tests", "fake_vst2.dll")
|
||||||
|
|
||||||
sf2utils = pytest.importorskip("sf2utils.sf2parse")
|
sf2utils = pytest.importorskip("sf2utils.sf2parse")
|
||||||
|
|
||||||
|
|
||||||
@@ -53,3 +57,20 @@ def test_write_sf2_odd_name_no_corruption(tmp_path, caplog):
|
|||||||
sf2 = sf2utils.Sf2File(f)
|
sf2 = sf2utils.Sf2File(f)
|
||||||
assert len([s for s in sf2.samples if s.end > s.start]) == 1
|
assert len([s for s in sf2.samples if s.end > s.start]) == 1
|
||||||
assert not any("corrupted" in r.message for r in caplog.records)
|
assert not any("corrupted" in r.message for r in caplog.records)
|
||||||
|
|
||||||
|
def test_autosample_vst2_native_bridge(tmp_path):
|
||||||
|
"""Nhanh VST2: render qua native bridge (fake_vst2.dll) → SF2 hop le,
|
||||||
|
khong can pedalboard. Skip tren nen khong phai Windows / thieu DLL."""
|
||||||
|
if sys.platform.startswith("win") and os.path.isfile(FAKE_VST2):
|
||||||
|
from tools.autosample_vsti import autosample_sf2
|
||||||
|
out = str(tmp_path / "vst2.sf2")
|
||||||
|
res = autosample_sf2("fake_vst2", out, low=60, high=64, step=2,
|
||||||
|
duration=0.3, release=0.2,
|
||||||
|
extra_vst_dirs=[os.path.dirname(FAKE_VST2)])
|
||||||
|
assert res["note_count"] == 3
|
||||||
|
assert res["size_bytes"] == os.path.getsize(out)
|
||||||
|
with open(out, "rb") as f:
|
||||||
|
sf2 = sf2utils.Sf2File(f)
|
||||||
|
assert len([s for s in sf2.samples if s.end > s.start]) == 3
|
||||||
|
else:
|
||||||
|
pytest.skip("fake_vst2.dll chi chay Windows (bridge ctypes WinDLL)")
|
||||||
|
|||||||
+70
-16
@@ -64,20 +64,20 @@ def write_sf2(out_path, samples, sample_rate=44100, name="AutoSampled"):
|
|||||||
phdr += name.encode("ascii", "replace")[:20].ljust(20, b"\x00")
|
phdr += name.encode("ascii", "replace")[:20].ljust(20, b"\x00")
|
||||||
phdr += struct.pack("<HHHIII", 0, 0, 0, 0, 0, 0) # preset=0 bank=0 bagNdx=0 libr genre morph
|
phdr += struct.pack("<HHHIII", 0, 0, 0, 0, 0, 0) # preset=0 bank=0 bagNdx=0 libr genre morph
|
||||||
phdr += b"EOP".ljust(20, b"\x00") + struct.pack("<HHHIII", 0, 0, 1, 0, 0, 0)
|
phdr += b"EOP".ljust(20, b"\x00") + struct.pack("<HHHIII", 0, 0, 1, 0, 0, 0)
|
||||||
pbag = struct.pack("<HH", 0, 0) + struct.pack("<HH", 0, 0)
|
pbag = struct.pack("<HH", 0, 0) + struct.pack("<HH", 1, 0)
|
||||||
pmod = b"\x00" * 10
|
pmod = b"\x00" * 10
|
||||||
pgen = struct.pack("<HH", 0, 0)
|
pgen = struct.pack("<HH", 41, 0) + struct.pack("<HH", 0, 0)
|
||||||
inst = bytearray()
|
inst = bytearray()
|
||||||
inst += b"AutoSampled".ljust(20, b"\x00") + struct.pack("<H", 0)
|
inst += b"AutoSampled".ljust(20, b"\x00") + struct.pack("<H", 0)
|
||||||
inst += b"EOI".ljust(20, b"\x00") + struct.pack("<H", 1)
|
inst += b"EOI".ljust(20, b"\x00") + struct.pack("<H", n) # bagNdx = chi so record terminal zone (ibag co n+1 records)
|
||||||
ibag = b"".join(struct.pack("<HH", i * 3, 0) for i in range(n + 1))
|
ibag = b"".join(struct.pack("<HH", i * 3, 0) for i in range(n + 1))
|
||||||
imod = b"\x00" * 10
|
imod = b"\x00" * 10
|
||||||
igen = bytearray()
|
igen = bytearray()
|
||||||
for i, s in enumerate(samples):
|
for i, s in enumerate(samples):
|
||||||
note = int(s["note"]) & 0xFF
|
note = int(s["note"]) & 0xFF
|
||||||
igen += struct.pack("<HH", 60, note | (note << 8)) # keyRange lo=hi=note
|
igen += struct.pack("<HH", 43, note | (note << 8)) # keyRange lo=hi=note
|
||||||
igen += struct.pack("<HH", 69, i) # sampleID
|
igen += struct.pack("<HH", 58, note) # overridingRootKey
|
||||||
igen += struct.pack("<HH", 74, note) # overridingRootKey
|
igen += struct.pack("<HH", 53, i) # sampleID (phai la gen cuoi zone)
|
||||||
igen += struct.pack("<HH", 0, 0)
|
igen += struct.pack("<HH", 0, 0)
|
||||||
shdr = bytearray()
|
shdr = bytearray()
|
||||||
for i, s in enumerate(samples):
|
for i, s in enumerate(samples):
|
||||||
@@ -118,34 +118,88 @@ def _render_note(vst, note, sr, dur, release, velocity):
|
|||||||
return np.clip(audio * 32767, -32768, 32767).astype(np.int16)
|
return np.clip(audio * 32767, -32768, 32767).astype(np.int16)
|
||||||
|
|
||||||
|
|
||||||
|
def _postprocess(buf, sr):
|
||||||
|
"""Stereo float32 (2,N) -> int16 mono, peak 0.9 (chung cho 2 engine)."""
|
||||||
|
mono = buf.mean(axis=0)
|
||||||
|
peak = float(np.max(np.abs(mono)))
|
||||||
|
if peak < 1e-5:
|
||||||
|
return None
|
||||||
|
above = np.nonzero(np.abs(mono) > peak * 0.001)[0]
|
||||||
|
start = int(above[0]) if above.size else 0
|
||||||
|
audio = mono[start:] / peak * 0.9
|
||||||
|
return np.clip(audio * 32767, -32768, 32767).astype(np.int16)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_vst2_path(path):
|
||||||
|
"""VST2 = .dll/.so dung rieng (Windows VST3 la FOLDER .vst3, .so VST3
|
||||||
|
co duoi .vst3.so) — native bridge render duoc; pedalboard thi khong."""
|
||||||
|
low = (path or "").lower()
|
||||||
|
if ".vst3" in low:
|
||||||
|
return False
|
||||||
|
return low.endswith(".dll") or low.endswith(".so")
|
||||||
|
|
||||||
|
|
||||||
|
def _render_note_native(plugin_path, note, sr, dur, release, velocity):
|
||||||
|
"""VST2 qua native bridge (Windows) — pedalboard khong ho tro VST2."""
|
||||||
|
from app.core.native_audio_service import get_service
|
||||||
|
bpm = 120.0
|
||||||
|
y = get_service().render_vst2_offline(
|
||||||
|
plugin_path,
|
||||||
|
[{"start_beat": 0.0, "duration_beats": dur * bpm / 60.0,
|
||||||
|
"note": int(note) & 0x7F, "velocity": int(velocity)}],
|
||||||
|
bpm=bpm, sr=sr, tail_sec=release,
|
||||||
|
)
|
||||||
|
return _postprocess(y, sr)
|
||||||
|
|
||||||
|
|
||||||
def autosample_sf2(instrument_id, out_path, preset_id=None, preset_path=None,
|
def autosample_sf2(instrument_id, out_path, preset_id=None, preset_path=None,
|
||||||
preset_data_b64=None, low=36, high=96, step=2, duration=2.5,
|
preset_data_b64=None, low=36, high=96, step=2, duration=2.5,
|
||||||
release=1.0, velocity=100, sample_rate=44100, name=None, log=None):
|
release=1.0, velocity=100, sample_rate=44100, name=None, log=None,
|
||||||
|
extra_vst_dirs=None):
|
||||||
"""Auto-sample VSTi -> SF2 (16-bit mono). Dung cho server endpoint
|
"""Auto-sample VSTi -> SF2 (16-bit mono). Dung cho server endpoint
|
||||||
/api/v1/plugins/autosample va CLI main().
|
/api/v1/plugins/autosample va CLI main().
|
||||||
|
|
||||||
|
VST3 -> pedalboard (ap preset neu co); VST2 (.dll/.so khong .vst3) ->
|
||||||
|
native bridge (Windows) — pedalboard khong ho tro VST2, preset VST2 chua
|
||||||
|
qua bridge duoc nen bo qua.
|
||||||
|
|
||||||
Returns dict {note_count, out_path, size_bytes}.
|
Returns dict {note_count, out_path, size_bytes}.
|
||||||
Raises FileNotFoundError neu plugin khong tim thay, RuntimeError neu
|
Raises FileNotFoundError neu plugin khong tim thay, RuntimeError neu
|
||||||
pedalboard khong kha dung hoac khong render duoc not nao.
|
engine khong kha dung hoac khong render duoc not nao.
|
||||||
"""
|
"""
|
||||||
from app.core.vst_engine import PluginManager, apply_preset_to_plugin, HAS_PEDALBOARD
|
from app.core.vst_engine import PluginManager, apply_preset_to_plugin, HAS_PEDALBOARD
|
||||||
if not HAS_PEDALBOARD:
|
|
||||||
raise RuntimeError("pedalboard khong kha dung - khong auto-sample duoc")
|
|
||||||
|
|
||||||
vst = PluginManager().load_vst(instrument_id)
|
plugin_path = PluginManager(extra_vst_dirs=extra_vst_dirs or [])._scan_plugins().get(instrument_id)
|
||||||
if vst is None:
|
if not plugin_path:
|
||||||
raise FileNotFoundError(f"Khong tim thay VSTi: {instrument_id} - hay Scan trong Plugin Manager truoc")
|
raise FileNotFoundError(f"Khong tim thay VSTi: {instrument_id} - hay Scan trong Plugin Manager truoc")
|
||||||
if preset_id or preset_path or preset_data_b64:
|
|
||||||
apply_preset_to_plugin(vst, preset_id=preset_id, preset_path=preset_path,
|
|
||||||
preset_data_b64=preset_data_b64)
|
|
||||||
|
|
||||||
def _log(msg):
|
def _log(msg):
|
||||||
if log:
|
if log:
|
||||||
log(msg)
|
log(msg)
|
||||||
|
|
||||||
|
if _is_vst2_path(plugin_path):
|
||||||
|
# VST2: native bridge (Windows) — pedalboard khong ho tro. Preset chua
|
||||||
|
# export qua bridge (VST2AudioEngine chua co chunk/preset) -> bo qua.
|
||||||
|
if preset_id or preset_path or preset_data_b64:
|
||||||
|
_log("VST2: native bridge chua ho tro preset - bo qua, sample preset mac dinh")
|
||||||
|
def _render(note):
|
||||||
|
return _render_note_native(plugin_path, note, sample_rate, duration,
|
||||||
|
release, velocity)
|
||||||
|
else:
|
||||||
|
if not HAS_PEDALBOARD:
|
||||||
|
raise RuntimeError("pedalboard khong kha dung - khong auto-sample VST3 duoc")
|
||||||
|
vst = PluginManager(extra_vst_dirs=extra_vst_dirs or []).load_vst(instrument_id)
|
||||||
|
if vst is None:
|
||||||
|
raise FileNotFoundError(f"Khong tim thay VSTi: {instrument_id} - hay Scan trong Plugin Manager truoc")
|
||||||
|
if preset_id or preset_path or preset_data_b64:
|
||||||
|
apply_preset_to_plugin(vst, preset_id=preset_id, preset_path=preset_path,
|
||||||
|
preset_data_b64=preset_data_b64)
|
||||||
|
def _render(note):
|
||||||
|
return _render_note(vst, note, sample_rate, duration, release, velocity)
|
||||||
|
|
||||||
samples = []
|
samples = []
|
||||||
for note in range(low, high + 1, step):
|
for note in range(low, high + 1, step):
|
||||||
frames = _render_note(vst, note, sample_rate, duration, release, velocity)
|
frames = _render(note)
|
||||||
if frames is None:
|
if frames is None:
|
||||||
_log(f"note {note}: silent, bo qua")
|
_log(f"note {note}: silent, bo qua")
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in New Issue
Block a user