386 lines
16 KiB
Python
386 lines
16 KiB
Python
# SonicForge Studio VST / VSTi Engine Service
|
|
import os
|
|
import numpy as np
|
|
import functools
|
|
from ctypes import c_char_p
|
|
|
|
def midi_note_to_freq(note_number: int) -> float:
|
|
return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
|
|
|
|
def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float = 120.0, instrument: str = 'synth') -> np.ndarray:
|
|
beat_duration_sec = 60.0 / max(30.0, bpm)
|
|
max_duration_sec = 2.0
|
|
for event in midi_events:
|
|
start_beat = event.get('start_beat', 0.0)
|
|
dur_beats = event.get('duration_beats', 1.0)
|
|
end_sec = (start_beat + dur_beats) * beat_duration_sec
|
|
if end_sec > max_duration_sec:
|
|
max_duration_sec = end_sec
|
|
total_samples = int((max_duration_sec + 0.5) * sr)
|
|
out_l = np.zeros(total_samples, dtype=np.float32)
|
|
out_r = np.zeros(total_samples, dtype=np.float32)
|
|
for event in midi_events:
|
|
note = event.get('note', 60)
|
|
velocity = event.get('velocity', 100) / 127.0
|
|
start_beat = event.get('start_beat', 0.0)
|
|
dur_beats = event.get('duration_beats', 1.0)
|
|
start_sample = int(start_beat * beat_duration_sec * sr)
|
|
dur_samples = int(dur_beats * beat_duration_sec * sr)
|
|
end_sample = min(total_samples, start_sample + dur_samples)
|
|
actual_len = end_sample - start_sample
|
|
if actual_len <= 0 or start_sample >= total_samples:
|
|
continue
|
|
freq = midi_note_to_freq(note)
|
|
t = np.arange(actual_len) / float(sr)
|
|
tone = 0.6 * np.sin(2 * np.pi * freq * t) + 0.3 * np.sin(2 * np.pi * freq * 2 * t) + 0.1 * np.sin(2 * np.pi * freq * 3 * t)
|
|
attack = min(int(0.01 * sr), actual_len // 4)
|
|
release = min(int(0.05 * sr), actual_len // 4)
|
|
env = np.ones(actual_len, dtype=np.float32)
|
|
if attack > 0:
|
|
env[:attack] = np.linspace(0.0, 1.0, attack)
|
|
if release > 0:
|
|
env[-release:] = np.linspace(1.0, 0.0, release)
|
|
signal = tone * env * velocity
|
|
out_l[start_sample:end_sample] += signal
|
|
out_r[start_sample:end_sample] += signal
|
|
max_peak = max(np.max(np.abs(out_l)), np.max(np.abs(out_r)))
|
|
if max_peak > 1.0:
|
|
out_l /= max_peak
|
|
out_r /= max_peak
|
|
return np.vstack([out_l, out_r])
|
|
|
|
def check_pedalboard_safe():
|
|
import subprocess, sys
|
|
try:
|
|
res = subprocess.run(
|
|
[sys.executable, "-c", "import pedalboard"],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
|
|
)
|
|
return res.returncode == 0
|
|
except Exception:
|
|
return False
|
|
|
|
def check_pyfluidsynth_safe():
|
|
import subprocess, sys
|
|
try:
|
|
res = subprocess.run(
|
|
[sys.executable, "-c", "import fluidsynth"],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0
|
|
)
|
|
return res.returncode == 0
|
|
except Exception:
|
|
return False
|
|
|
|
HAS_PEDALBOARD = check_pedalboard_safe()
|
|
HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe()
|
|
|
|
def ensure_pyfluidsynth():
|
|
global HAS_PYFLUIDSYNTH
|
|
if not HAS_PYFLUIDSYNTH:
|
|
HAS_PYFLUIDSYNTH = check_pyfluidsynth_safe()
|
|
return HAS_PYFLUIDSYNTH
|
|
|
|
if HAS_PEDALBOARD:
|
|
try:
|
|
from pedalboard import VST3Plugin, Pedalboard, Gain, MidiMessage
|
|
except Exception:
|
|
HAS_PEDALBOARD = False
|
|
|
|
if HAS_PYFLUIDSYNTH:
|
|
try:
|
|
import fluidsynth
|
|
except Exception:
|
|
HAS_PYFLUIDSYNTH = False
|
|
|
|
|
|
# ── Module-level caches ──
|
|
_FLUID_CACHE = {} # path → (fluidsynth.FluidSynth, refcount)
|
|
_PLUGIN_MANAGER_INSTANCE = None
|
|
_PLUGIN_MANAGER_ARGS = None
|
|
_SF_INSTRUMENTS_CACHE = {} # sf_id → list[presets]
|
|
|
|
def get_plugin_manager(vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None) -> "PluginManager":
|
|
"""Singleton: reuse PluginManager when args match, else create new."""
|
|
global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS
|
|
args = (vst_dir, sf_dir, upload_sf_dir)
|
|
if _PLUGIN_MANAGER_INSTANCE is not None and _PLUGIN_MANAGER_ARGS == args:
|
|
return _PLUGIN_MANAGER_INSTANCE
|
|
_PLUGIN_MANAGER_ARGS = args
|
|
_PLUGIN_MANAGER_INSTANCE = PluginManager(vst_dir, sf_dir, upload_sf_dir)
|
|
return _PLUGIN_MANAGER_INSTANCE
|
|
|
|
def load_soundfont_cached(path: str):
|
|
"""Return a cached low-level FluidSynth instance for path, incrementing refcount.
|
|
|
|
Uses the CFFI binding API (new_fluid_synth / fluid_synth_sfload) — the same
|
|
API render_engine relies on. The high-level `FluidSynth()`/`Synth()` classes
|
|
do not exist in this binding, so they are never used here.
|
|
"""
|
|
global _FLUID_CACHE
|
|
if not HAS_PYFLUIDSYNTH:
|
|
return None
|
|
if path in _FLUID_CACHE:
|
|
fl, ref = _FLUID_CACHE[path]
|
|
_FLUID_CACHE[path] = (fl, ref + 1)
|
|
return fl
|
|
try:
|
|
import fluidsynth as _fs
|
|
_settings = _fs.new_fluid_settings()
|
|
_fs.fluid_settings_setnum(_settings, b'synth.sample-rate', 44100.0)
|
|
fl = _fs.new_fluid_synth(_settings)
|
|
font_id = _fs.fluid_synth_sfload(fl, path.encode("utf-8"), 1)
|
|
if font_id < 0:
|
|
_fs.delete_fluid_synth(fl)
|
|
return None
|
|
_fs.fluid_synth_program_select(fl, 0, font_id, 0, 0)
|
|
_FLUID_CACHE[path] = (fl, 1)
|
|
return fl
|
|
except Exception:
|
|
return None
|
|
|
|
def release_soundfont(path: str):
|
|
"""Decrement refcount; delete FluidSynth when count reaches 0."""
|
|
global _FLUID_CACHE
|
|
if path not in _FLUID_CACHE:
|
|
return
|
|
fl, ref = _FLUID_CACHE[path]
|
|
if ref <= 1:
|
|
try:
|
|
import fluidsynth as _fs
|
|
_fs.delete_fluid_synth(fl)
|
|
except Exception:
|
|
pass
|
|
del _FLUID_CACHE[path]
|
|
else:
|
|
_FLUID_CACHE[path] = (fl, ref - 1)
|
|
|
|
class PluginManager:
|
|
def __init__(self, vst_dir="/opt/daw_engine/vst3", sf_dir="/opt/daw_engine/soundfonts", upload_sf_dir=None):
|
|
self.vst_dir = vst_dir
|
|
self.sf_dir = sf_dir
|
|
self.upload_sf_dir = upload_sf_dir
|
|
self._sf_scan_cache = None # cache for _scan_soundfonts()
|
|
|
|
def _scan_plugins(self) -> dict:
|
|
plugins = {}
|
|
if not os.path.isdir(self.vst_dir):
|
|
return plugins
|
|
for root, dirs, files in os.walk(self.vst_dir):
|
|
for file in files:
|
|
if file.endswith(".vst3") or file.endswith(".so"):
|
|
plugin_path = os.path.join(root, file)
|
|
plugin_name = os.path.splitext(file)[0]
|
|
plugins[plugin_name] = plugin_path
|
|
return plugins
|
|
|
|
def _scan_soundfonts(self) -> list:
|
|
sf_map = {}
|
|
dirs = [("system", self.sf_dir)]
|
|
if self.upload_sf_dir and self.upload_sf_dir != self.sf_dir:
|
|
dirs.append(("upload", self.upload_sf_dir))
|
|
|
|
meta_cache = {}
|
|
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir):
|
|
for f in os.listdir(self.upload_sf_dir):
|
|
if f.endswith(".meta"):
|
|
try:
|
|
import json
|
|
with open(os.path.join(self.upload_sf_dir, f), "r") as mf:
|
|
meta_cache[os.path.splitext(f)[0]] = json.load(mf)
|
|
except Exception:
|
|
pass
|
|
|
|
for source, d in dirs:
|
|
if not os.path.isdir(d):
|
|
continue
|
|
for f in os.listdir(d):
|
|
if f.endswith(".sf2") or f.endswith(".sf3"):
|
|
base_id = os.path.splitext(f)[0]
|
|
if base_id in sf_map:
|
|
continue
|
|
meta = meta_cache.get(base_id, None)
|
|
if meta:
|
|
display_name = meta.get("original_name", f)
|
|
else:
|
|
short_id = base_id[:8] if len(base_id) > 8 else base_id
|
|
display_name = f"SoundFont_{short_id}"
|
|
sf_map[base_id] = {
|
|
"id": base_id,
|
|
"name": display_name,
|
|
"file": f,
|
|
"display": os.path.splitext(display_name)[0][:40],
|
|
"source": source
|
|
}
|
|
return list(sf_map.values())
|
|
|
|
def load_vst(self, plugin_name: str, preset_data: dict = None):
|
|
if not HAS_PEDALBOARD:
|
|
return None
|
|
# Ưu tiên PATH trực tiếp (spec desktop 2026-08-08: synth_engine.plugin_path)
|
|
if plugin_name and (plugin_name.endswith(".vst3") or plugin_name.endswith(".so")
|
|
or plugin_name.endswith(".component") or "/" in plugin_name or "\\" in plugin_name):
|
|
if os.path.exists(plugin_name):
|
|
vst = VST3Plugin(plugin_name)
|
|
if preset_data:
|
|
for k, v in preset_data.items():
|
|
try:
|
|
setattr(vst, k, v)
|
|
except Exception:
|
|
pass
|
|
return vst
|
|
return None
|
|
plugins = self._scan_plugins()
|
|
if plugin_name not in plugins:
|
|
return None
|
|
path = plugins[plugin_name]
|
|
vst = VST3Plugin(path)
|
|
if preset_data:
|
|
for k, v in preset_data.items():
|
|
try:
|
|
setattr(vst, k, v)
|
|
except Exception:
|
|
pass
|
|
return vst
|
|
|
|
def _scan_soundfonts_cached(self):
|
|
if self._sf_scan_cache is not None:
|
|
return self._sf_scan_cache
|
|
self._sf_scan_cache = self._scan_soundfonts()
|
|
return self._sf_scan_cache
|
|
|
|
def load_soundfont(self, path: str):
|
|
return load_soundfont_cached(path)
|
|
|
|
def list_soundfont_instruments(self, sf_id: str):
|
|
if not ensure_pyfluidsynth():
|
|
return []
|
|
if sf_id in _SF_INSTRUMENTS_CACHE:
|
|
return _SF_INSTRUMENTS_CACHE[sf_id]
|
|
search_dirs = []
|
|
if os.path.isdir(self.sf_dir):
|
|
search_dirs.append(self.sf_dir)
|
|
if self.upload_sf_dir and os.path.isdir(self.upload_sf_dir) and self.upload_sf_dir != self.sf_dir:
|
|
search_dirs.append(self.upload_sf_dir)
|
|
for d in search_dirs:
|
|
for f in os.listdir(d):
|
|
if not (f.endswith(".sf2") or f.endswith(".sf3")):
|
|
continue
|
|
base = os.path.splitext(f)[0]
|
|
if base == sf_id or base == sf_id.replace("sf_", ""):
|
|
path = os.path.join(d, f)
|
|
try:
|
|
import fluidsynth as _fs
|
|
# Low-level CFFI API (same as render_engine); never use
|
|
# the high-level Synth() class that this binding lacks.
|
|
_settings = _fs.new_fluid_settings()
|
|
_synth = _fs.new_fluid_synth(_settings)
|
|
try:
|
|
fid = _fs.fluid_synth_sfload(_synth, path.encode("utf-8"), 1)
|
|
if fid < 0:
|
|
continue
|
|
sfont = _fs.fluid_synth_get_sfont_by_id(_synth, fid)
|
|
presets = []
|
|
if sfont:
|
|
for bank in range(0, 2):
|
|
for prog_num in range(0, 128):
|
|
try:
|
|
preset = _fs.fluid_sfont_get_preset(sfont, bank, prog_num)
|
|
except Exception:
|
|
break
|
|
if preset:
|
|
try:
|
|
name_ptr = _fs.fluid_preset_get_name(preset)
|
|
if name_ptr:
|
|
if hasattr(_fs, "ffi"):
|
|
raw = _fs.ffi.string(name_ptr)
|
|
else:
|
|
raw = c_char_p(name_ptr).value
|
|
if raw:
|
|
presets.append({
|
|
"bank": bank,
|
|
"program": prog_num,
|
|
"name": raw.decode("utf-8", errors="replace")
|
|
})
|
|
except Exception:
|
|
continue
|
|
_SF_INSTRUMENTS_CACHE[sf_id] = presets[:256]
|
|
return presets[:256]
|
|
finally:
|
|
try:
|
|
_fs.delete_fluid_synth(_synth)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
import traceback; traceback.print_exc()
|
|
_SF_INSTRUMENTS_CACHE[sf_id] = []
|
|
return []
|
|
|
|
def list_available(self) -> dict:
|
|
return {
|
|
"vst_instruments": [
|
|
{"id": "vst3_" + k.lower().replace(" ", "_"), "name": k, "type": "VST3",
|
|
"path": p, "has_native_support": HAS_PEDALBOARD}
|
|
for k, p in self._scan_plugins().items()
|
|
],
|
|
"soundfonts": self._scan_soundfonts()
|
|
}
|
|
|
|
@staticmethod
|
|
def midi_events_to_messages(midi_events: list, bpm: float, sr: int, bank: int = None, program: int = None) -> list:
|
|
if not HAS_PEDALBOARD:
|
|
return []
|
|
beat_duration_sec = 60.0 / max(30.0, bpm)
|
|
messages = []
|
|
if bank is not None:
|
|
messages.append(MidiMessage(control_change=0, value=bank, sample_offset=0))
|
|
if program is not None:
|
|
messages.append(MidiMessage(program_change=program, sample_offset=0))
|
|
for ev in midi_events:
|
|
note = ev.get("note", 60)
|
|
velocity = ev.get("velocity", 100)
|
|
start_beat = ev.get("start_beat", 0.0)
|
|
dur_beats = ev.get("duration_beats", 1.0)
|
|
start_sec = start_beat * beat_duration_sec
|
|
dur_sec = dur_beats * beat_duration_sec
|
|
sample_offset = int(start_sec * sr)
|
|
end_sample_offset = int((start_sec + dur_sec) * sr)
|
|
messages.append(MidiMessage(note_on=note, velocity=velocity, sample_offset=sample_offset))
|
|
messages.append(MidiMessage(note_off=note, velocity=0, sample_offset=end_sample_offset))
|
|
return messages
|
|
|
|
@staticmethod
|
|
def validate_sf2_header(data: bytes) -> bool:
|
|
if len(data) < 12:
|
|
return False
|
|
if data[0:4] != b'RIFF':
|
|
return False
|
|
if data[8:12] != b'sfbk':
|
|
return False
|
|
return True
|
|
|
|
|
|
class DecentSamplerManager:
|
|
def __init__(self, vst_path="/opt/daw_engine/vst3/DecentSampler.vst3"):
|
|
self.vst_path = vst_path
|
|
|
|
def create_decent_sampler_instance(self, dspreset_path: str):
|
|
if not HAS_PEDALBOARD:
|
|
raise RuntimeError("pedalboard not available")
|
|
if not os.path.exists(self.vst_path):
|
|
raise FileNotFoundError(f"DecentSampler VST3 not found at {self.vst_path}")
|
|
if not os.path.exists(dspreset_path):
|
|
raise FileNotFoundError(f"Preset file not found at {dspreset_path}")
|
|
|
|
plugin = VST3Plugin(self.vst_path)
|
|
|
|
abs_preset = os.path.abspath(dspreset_path)
|
|
preset_dir = os.path.dirname(abs_preset)
|
|
cwd_before = os.getcwd()
|
|
try:
|
|
os.chdir(preset_dir)
|
|
plugin.load_preset(abs_preset)
|
|
finally:
|
|
os.chdir(cwd_before)
|
|
|
|
return plugin
|