Files
SonicForgeStudio/tools/autosample_vsti.py
T

251 lines
11 KiB
Python

"""Auto-sample VSTi presets -> SF2/SF3 cho client-side WASM live preview.
Spec (Option 1): "a custom Python script" render preset qua tung not -> .sf3
(~3-8MB) de FluidSynth WASM phat realtime voi 0% IPC. Script nay render not
bang pedalboard (DUNG VSTi + preset nhu export) -> SF2 (16-bit mono PCM,
1 zone/not). --sf3 chuyen tiep qua SoundFontConverter (can ffmpeg libvorbis).
Usage:
python tools/autosample_vsti.py --instrument <plugin_id> --out out.sf2 \
[--preset <path>] [--low 36 --high 96 --step 2] [--sf3]
Ket qua dat vao app/storage/soundfonts/ de client tai qua /soundfonts/download.
"""
import argparse
import os
import struct
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import numpy as np
def write_sf2(out_path, samples, sample_rate=44100, name="AutoSampled"):
"""Ghi SF2 toi gian hop le. samples: list[dict] voi note:int, frames:
np.int16 mono 1D (audio da render cua not do)."""
if not samples:
raise ValueError("no samples")
def chunk(cid, data):
out = bytearray(cid) + struct.pack("<I", len(data)) + data
if len(data) % 2:
out += b"\x00"
return bytes(out)
def lst(ftype, chunks):
return chunk(b"LIST", ftype + b"".join(chunks))
# --- sdta: PCM 16-bit mono, end exclusive (cung convention SF3 converter) ---
pcm = bytearray()
offsets = []
for s in samples:
frames = np.asarray(s["frames"], dtype=np.int16)
offsets.append((len(pcm) // 2, frames.shape[0]))
pcm += frames.tobytes()
if len(pcm) % 2:
pcm += b"\x00"
sdta = lst(b"sdta", [chunk(b"smpl", bytes(pcm))])
# --- INFO ---
inam = name.encode("ascii", "replace")
if len(inam) % 2:
inam += b"\x00" # INFO text chunks phai chan (sf2utils/strict parsers khong skip pad byte)
info = lst(b"INFO", [
chunk(b"ifil", struct.pack("<HH", 2, 1)),
chunk(b"INAM", inam),
chunk(b"iver", struct.pack("<HH", 2, 1)),
])
# --- pdta ---
n = len(samples)
phdr = bytearray()
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 += b"EOP".ljust(20, b"\x00") + struct.pack("<HHHIII", 0, 0, 1, 0, 0, 0)
pbag = struct.pack("<HH", 0, 0) + struct.pack("<HH", 1, 0)
pmod = b"\x00" * 10
pgen = struct.pack("<HH", 41, 0) + struct.pack("<HH", 0, 0)
inst = bytearray()
inst += b"AutoSampled".ljust(20, b"\x00") + struct.pack("<H", 0)
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))
imod = b"\x00" * 10
igen = bytearray()
for i, s in enumerate(samples):
note = int(s["note"]) & 0xFF
igen += struct.pack("<HH", 43, note | (note << 8)) # keyRange lo=hi=note
igen += struct.pack("<HH", 58, note) # overridingRootKey
igen += struct.pack("<HH", 53, i) # sampleID (phai la gen cuoi zone)
igen += struct.pack("<HH", 0, 0)
shdr = bytearray()
for i, s in enumerate(samples):
note = int(s["note"])
start, frames = offsets[i]
shdr += f"note{note:03d}".encode()[:20].ljust(20, b"\x00")
shdr += struct.pack("<IIIIi", start, start + frames, 0, 0, sample_rate)
shdr += struct.pack("<BBH", note, 0, 0) # originalPitch correction sampleLink
shdr += struct.pack("<H", 1) # sampleType: mono
shdr += b"\x00" * 46 # terminator
pdta = lst(b"pdta", [
chunk(b"phdr", bytes(phdr)), chunk(b"pbag", pbag), chunk(b"pmod", pmod),
chunk(b"pgen", pgen), chunk(b"inst", bytes(inst)), chunk(b"ibag", ibag),
chunk(b"imod", imod), chunk(b"igen", bytes(igen)), chunk(b"shdr", bytes(shdr)),
])
body = info + sdta + pdta
out = bytearray(b"RIFF") + struct.pack("<I", 4 + len(body)) + b"sfbk" + body
with open(out_path, "wb") as f:
f.write(out)
return out_path
def _render_note(vst, note, sr, dur, release, velocity):
# pedalboard >= 0.9: MIDI messages la tuple (bytes raw MIDI, timestamp_seconds)
messages = [
(bytes([0x90, int(note) & 0x7F, max(0, min(127, velocity))]), 0.0),
(bytes([0x80, int(note) & 0x7F, 0]), dur),
]
buf = vst(messages, sample_rate=sr, duration=dur + release, num_channels=2)
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 _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,
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,
extra_vst_dirs=None):
"""Auto-sample VSTi -> SF2 (16-bit mono). Dung cho server endpoint
/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}.
Raises FileNotFoundError neu plugin khong tim thay, RuntimeError neu
engine khong kha dung hoac khong render duoc not nao.
"""
from app.core.vst_engine import PluginManager, apply_preset_to_plugin, HAS_PEDALBOARD
plugin_path = PluginManager(extra_vst_dirs=extra_vst_dirs or [])._scan_plugins().get(instrument_id)
if not plugin_path:
raise FileNotFoundError(f"Khong tim thay VSTi: {instrument_id} - hay Scan trong Plugin Manager truoc")
def _log(msg):
if log:
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 = []
for note in range(low, high + 1, step):
frames = _render(note)
if frames is None:
_log(f"note {note}: silent, bo qua")
continue
samples.append({"note": note, "frames": frames})
_log(f"note {note}: {frames.shape[0] / sample_rate:.2f}s")
if not samples:
raise RuntimeError("Khong render duoc not nao (plugin silent?)")
if name is None:
name = os.path.basename(instrument_id)
write_sf2(out_path, samples, sample_rate, name=name)
size = os.path.getsize(out_path)
_log(f"wrote {out_path} ({size // 1024} KB, {len(samples)} not)")
return {"note_count": len(samples), "out_path": out_path, "size_bytes": size}
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("--instrument", required=True, help="plugin_id da scan (Plugin Manager)")
ap.add_argument("--out", required=True, help="duong dan .sf2 (hoac .sf3 voi --sf3)")
ap.add_argument("--preset", default=None, help="duong dan preset .vstpreset/.fxp/.fxb")
ap.add_argument("--low", type=int, default=36)
ap.add_argument("--high", type=int, default=96)
ap.add_argument("--step", type=int, default=2, help="buoc not (2 = nua cung)")
ap.add_argument("--duration", type=float, default=2.5, help="giay giu not")
ap.add_argument("--release", type=float, default=1.0, help="giay duoi sau note-off")
ap.add_argument("--velocity", type=int, default=100)
ap.add_argument("--sample-rate", type=int, default=44100)
ap.add_argument("--sf3", action="store_true", help="convert SF2 -> SF3 sau khi sample")
args = ap.parse_args()
try:
result = autosample_sf2(
args.instrument, args.out, preset_path=args.preset,
low=args.low, high=args.high, step=args.step,
duration=args.duration, release=args.release,
velocity=args.velocity, sample_rate=args.sample_rate,
)
except (FileNotFoundError, RuntimeError) as e:
sys.exit(str(e))
print(f"wrote {result['out_path']} ({result['size_bytes'] // 1024} KB, {result['note_count']} not)")
if args.sf3:
from app.core.soundfont_converter import SoundFontConverter
p = SoundFontConverter().convert_sf2_to_sf3(args.out)
print("sf3:", p)
if __name__ == "__main__":
main()