feat: VSTi autosample -> SF2/SF3 (tools/autosample_vsti.py) + bit_depth 16/24/32 render/export WAV (render_engine, plugins API, audio_editor, app.jsx encoders) + tests + task/walkthrough docs (53)

This commit is contained in:
2026-08-11 12:09:02 +07:00
parent f8cb2c6a5e
commit 7293d7ac7e
12 changed files with 2266 additions and 18 deletions
+167
View File
@@ -0,0 +1,167 @@
"""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 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()
from app.core.vst_engine import PluginManager, apply_preset_to_plugin, HAS_PEDALBOARD
if not HAS_PEDALBOARD:
sys.exit("pedalboard khong kha dung - khong auto-sample duoc")
vst = PluginManager().load_vst(args.instrument)
if vst is None:
sys.exit(f"Khong tim thay VSTi: {args.instrument} - hay Scan trong Plugin Manager truoc")
if args.preset:
apply_preset_to_plugin(vst, preset_path=args.preset)
samples = []
for note in range(args.low, args.high + 1, args.step):
frames = _render_note(vst, note, args.sample_rate, args.duration, args.release, args.velocity)
if frames is None:
print(f"note {note}: silent, bo qua")
continue
samples.append({"note": note, "frames": frames})
print(f"note {note}: {frames.shape[0] / args.sample_rate:.2f}s")
if not samples:
sys.exit("Khong render duoc not nao (plugin silent?)")
write_sf2(args.out, samples, args.sample_rate, name=os.path.basename(args.instrument))
print(f"wrote {args.out} ({os.path.getsize(args.out) // 1024} KB, {len(samples)} 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()