197 lines
8.2 KiB
Python
197 lines
8.2 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", 0, 0)
|
|
pmod = b"\x00" * 10
|
|
pgen = 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", 1)
|
|
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", 60, note | (note << 8)) # keyRange lo=hi=note
|
|
igen += struct.pack("<HH", 69, i) # sampleID
|
|
igen += struct.pack("<HH", 74, note) # overridingRootKey
|
|
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 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):
|
|
"""Auto-sample VSTi -> SF2 (16-bit mono). Dung cho server endpoint
|
|
/api/v1/plugins/autosample va CLI main().
|
|
|
|
Returns dict {note_count, out_path, size_bytes}.
|
|
Raises FileNotFoundError neu plugin khong tim thay, RuntimeError neu
|
|
pedalboard khong kha dung hoac khong render duoc not nao.
|
|
"""
|
|
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)
|
|
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 _log(msg):
|
|
if log:
|
|
log(msg)
|
|
|
|
samples = []
|
|
for note in range(low, high + 1, step):
|
|
frames = _render_note(vst, note, sample_rate, duration, release, velocity)
|
|
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()
|