# SonicForge Studio VST / VSTi Engine Service import os import json import re import numpy as np import functools import threading from ctypes import c_char_p import subprocess # Native render (bridge/pyfluidsynth) không thread-safe: tuần tự hóa RENDER_LOCK = threading.RLock() def _render_locked(fn): """Chạy hàm native (VST3/pyfluidsynth) dưới RENDER_LOCK — không thread-safe.""" @functools.wraps(fn) def wrapper(*args, **kwargs): with RENDER_LOCK: return fn(*args, **kwargs) return wrapper def _ensure_fluidsynth_dll_on_path(): """Frozen (PyInstaller): pyfluidsynth tim dll qua ctypes.util.find_library (chi quet PATH). Dat libfluidsynth-3.dll ke exe roi them exe_dir vao PATH truoc khi import fluidsynth - nguoc lai: 'Couldn't find the FluidSynth library' -> /soundfont-render tra 501.""" import sys if getattr(sys, 'frozen', False): exe_dir = os.path.dirname(os.path.abspath(sys.executable)) if exe_dir and exe_dir not in os.environ.get('PATH', '').split(os.pathsep): os.environ['PATH'] = exe_dir + os.pathsep + os.environ.get('PATH', '') _ensure_fluidsynth_dll_on_path() def midi_note_to_freq(note_number: int) -> float: return 440.0 * (2.0 ** ((note_number - 69) / 12.0)) @_render_locked def render_soundfont_midi_to_audio(midi_events: list, sf_path: str, bank: int = 0, program: int = 0, sr: int = 44100, bpm: float = 120.0) -> np.ndarray: """Native FluidSynth (pyfluidsynth) — render MIDI events bằng soundfont THẬT. Dùng cho môi trường standalone: âm instrument xử lí trực tiếp trên OS (backend pyfluidsynth), không phải FluidSynthWASM trong browser. Xử lí đúng note CHỒNG nhau + đuôi release (event-stream noteon/noteoff). Trả stereo float32 (2, N). Raise RuntimeError nếu pyfluidsynth thiếu / SF không load được.""" import fluidsynth as _fs settings = _fs.new_fluid_settings() _fs.fluid_settings_setnum(settings, b'synth.sample-rate', float(sr)) synth = _fs.new_fluid_synth(settings) try: fid = _fs.fluid_synth_sfload(synth, os.fspath(sf_path).encode('utf-8'), 1) if fid == -1: raise RuntimeError(f"SoundFont load failed: {sf_path}") _fs.fluid_synth_program_select(synth, 0, fid, int(bank), int(program)) beat_sec = 60.0 / max(30.0, bpm) events = [] total_sec = 0.0 for ev in midi_events: start_s = int(float(ev.get('start_beat', 0)) * beat_sec * sr) dur_s = int(float(ev.get('duration_beats', 1)) * beat_sec * sr) note = int(ev.get('note', 60)) vel = min(127, max(1, int(float(ev.get('velocity', 100))))) events.append((start_s, 'on', note, vel)) events.append((start_s + dur_s, 'off', note, 0)) end_sec = (start_s + dur_s) / float(sr) if end_sec > total_sec: total_sec = end_sec events.sort(key=lambda e: e[0]) total_sec = max(total_sec, 0.25) + 0.5 # đuôi reverb/release n = int(total_sec * sr) out = np.zeros((2, n), dtype=np.float32) pos = 0 def _render_until(target: int): """Render từ pos tới target, CỘNG vào out (giữ âm đang ngân).""" nonlocal pos target = min(target, n) while pos < target: chunk = min(target - pos, 44100) block = _fs.fluid_synth_write_s16_stereo(synth, chunk) b = block.astype(np.float32).reshape(-1, 2).T / 32768.0 take = min(chunk, b.shape[1], target - pos) if take > 0: out[:, pos:pos + take] += b[:, :take] pos += take for t, kind, note, vel in events: if t > pos: _render_until(t) if pos >= n: break if kind == 'on': _fs.fluid_synth_noteon(synth, 0, note, vel) else: _fs.fluid_synth_noteoff(synth, 0, note) _render_until(n) # đuôi release của notes cuối return out finally: try: _fs.delete_fluid_synth(synth) except Exception: pass @_render_locked 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 _module_available(name: str) -> bool: """Kiem tra module co san khong — KHONG spawn subprocess. Truoc day dung subprocess.run([sys.executable, '-c', 'import X']) — khi app dong goi (PyInstaller frozen), sys.executable = daw_engine.exe -> subprocess chay CA ENGINE (bootloader bo qua '-c', chay desktop_engine) -> moi lan check lai sinh ra engine moi -> de quy spawn vo han (Task Manager day daw_engine, port 8000-8005 leo thang, load rat cham). find_spec() nhanh (micro-giay) va hoat dong ca source lan frozen. """ import importlib.util try: return importlib.util.find_spec(name) is not None except (ImportError, AttributeError, ValueError): return False def check_pyfluidsynth_safe(): return _module_available("fluidsynth") 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_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 _load_user_plugin_dirs() -> list: """Đọc plugin_dirs.json (Plugin Manager user chọn) — cùng file với plugins.py (STORAGE_DIR/plugin_dirs.json). Không import plugins.py để tránh vòng import (plugins.py import vst_engine).""" try: from app.config import settings as _st path = os.path.join(_st.STORAGE_DIR, "plugin_dirs.json") if os.path.exists(path): with open(path, "r", encoding="utf-8") as f: data = json.load(f) return [d for d in (data.get("plugin_dirs") or []) if d] except Exception: pass return [] def get_plugin_manager(vst_dir=None, sf_dir=None, upload_sf_dir=None) -> "PluginManager": """Singleton: reuse PluginManager when args match, else create new. Default dirs từ settings (env/.env/docker-compose hoặc user override). VST scan gộp thêm plugin_dirs user (Plugin Manager) — nút Synth phải liệt kê được VSTi đã scan.""" from app.config import settings as _st vst_dir = vst_dir or _st.VST_DIR sf_dir = sf_dir or _st.SOUNDFONT_DIR upload_sf_dir = upload_sf_dir or _st.STORAGE_DIR + "/soundfonts" extra = _load_user_plugin_dirs() global _PLUGIN_MANAGER_INSTANCE, _PLUGIN_MANAGER_ARGS args = (vst_dir, sf_dir, upload_sf_dir, tuple(extra)) 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, extra_vst_dirs=extra) 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) def _classify_fx_category(name, path=""): """Heuristic phân loại VST FX cho Plugin Picker (spec vst_fx_panel_integration). C++ scan có subCategoriesString (VD 'Fx|EQ') nhưng không emit ra JSON; dùng tên plugin để xếp category. Chỉ ảnh hưởng UI grouping, không ảnh hưởng audio.""" s = (" " + name + " " + path).lower() if any(k in s for k in ("eq", "equalizer", "filter", "graphic", "parametric")) or re.search(r"(^|[^a-z0-9])q([0-9]|[^a-z]|$)", s): return "equalizer" if any(k in s for k in ("reverb", "delay", "echo", "chorus", "hall", "space")): return "reverb_delay" if any(k in s for k in ("comp", "limiter", "maximizer", "gate", "leveler", "de-ess")): return "comp_limiter" if any(k in s for k in ("distortion", "drive", "satur", "overdrive", "fuzz", "amp")): return "distortion" return "utility" class PluginManager: def __init__(self, vst_dir=None, sf_dir=None, upload_sf_dir=None, extra_vst_dirs=None): from app.config import settings as _st self.vst_dir = vst_dir or _st.VST_DIR self.sf_dir = sf_dir or _st.SOUNDFONT_DIR self.upload_sf_dir = upload_sf_dir # Thư mục VST thêm (plugin_dirs user scan trong Plugin Manager) — # list_available() phải thấy VSTi user đã scan (bug: nút # Synth chỉ quét vst_dir env mặc định /opt/daw_engine/vst3). self.extra_vst_dirs = [d for d in (extra_vst_dirs or []) if d] self._sf_scan_cache = None # cache for _scan_soundfonts() self._fx_scan_cache = None # cache for list_fx() self._fx_scan_all = None # cache for list_scan_all() def _scan_plugins(self) -> dict: plugins = {} for scan_dir in [self.vst_dir] + self.extra_vst_dirs: if not scan_dir or not os.path.isdir(scan_dir): continue for root, dirs, files in os.walk(scan_dir): # Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong) for d in list(dirs): if d.lower().endswith(".vst3"): plugins[os.path.splitext(d)[0]] = os.path.join(root, d) for file in files: low = file.lower() if low.endswith(".vst3") or low.endswith(".so") or low.endswith(".dll"): plugin_path = os.path.join(root, file) plugin_name = os.path.splitext(file)[0] if plugin_name not in plugins: 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 _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 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) # Thư mục user thêm qua Plugin Manager (plugin_dirs — chứa cả VST lẫn # SoundFont): nếu không có, instrument của soundfont trong thư mục user # không liệt kê được (bug "nhấn tên SF không thấy instrument"). for d in (self.extra_vst_dirs or []): if d and os.path.isdir(d) and d not in search_dirs: search_dirs.append(d) presets = [] # ── 1) FluidSynth (nếu có lib) — đọc bank/program/name từ engine ── if ensure_pyfluidsynth(): for d in search_dirs: if presets: break 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 and base != sf_id.replace("sf_", ""): continue 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: sfont = _fs.fluid_synth_get_sfont_by_id(_synth, fid) 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 finally: try: _fs.delete_fluid_synth(_synth) except Exception: pass except Exception: import traceback; traceback.print_exc() break # đã xử lý file khớp # ── 2) Fallback: sf2utils đọc TRỰC TIẾP file (thuần Python) ── # Không cần libfluidsynth — quan trọng trên Windows khi thiếu DLL. # Đọc preset header (pdta/phdr) → bank/program/name như nhau. if not presets: try: from app.core.soundfont_inspector import SoundFontInspector insp = SoundFontInspector(system_sf_dir=self.sf_dir, upload_sf_dir=self.upload_sf_dir) for d in search_dirs: if presets: break 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_", ""): info = insp.inspect_sf2_file(os.path.join(d, f)) if info and info.get("instruments"): presets = info["instruments"] break except Exception: presets = [] _SF_INSTRUMENTS_CACHE[sf_id] = presets[:256] return presets[:256] def list_available(self) -> dict: plugins = self._scan_plugins() return { "vst_instruments": [ {"id": k, "name": k, "path": p, "type": "VST3" if p.lower().endswith(".vst3") else "VST2", "has_native_support": True} for k, p in plugins.items() ], "soundfonts": self._scan_soundfonts() } def list_fx(self, refresh=False) -> list: """VST3 effect plugins (is_fx) từ native_bridge --scan (SEH-safe). Bridge tự phân loại FX/instrument theo VST3 subCategory; VST2 (.dll/.so) không introspect được nên bridge báo instrument — bỏ qua ở đây. Cache process-wide; refresh=True quét lại (Plugins Manager → Scan). UNKNOWN entries (bridge không đọc được module — vd Ozone 11 treo khi scan) cũng được đưa vào đây: không xác định là instrument → mặc định xem như FX để MASTERING PANEL không mất plugin; nút Synth loại chúng qua is_unknown (xem plugins.py /available). """ all_entries = self.list_scan_all(refresh=refresh) results = [e for e in all_entries if e.get("is_fx") or e.get("is_unknown")] results.sort(key=lambda x: x["name"].lower()) self._fx_scan_cache = results return results def list_scan_all(self, refresh=False) -> list: """Toàn bộ kết quả `fx_vst_bridge --scan` (FX + instrument + unknown). Bridge scan giờ có timeout per-module (30s) và emit entry UNKNOWN (is_fx=false, is_instrument=false) cho module crash/hang (vd Ozone 11 Clarity) — không xếp bừa vào instrument. `/available` dùng entry unknown để LOẠI plugin đó khỏi nút Synth (không biết loại → không hiện là instrument), còn list_fx() chỉ lấy is_fx=true. """ if not refresh and self._fx_scan_all is not None: return self._fx_scan_all from app.core.native_render import find_fx_bridge_exe exe = find_fx_bridge_exe() entries = [] if exe: dirs = [d for d in [self.vst_dir] + self.extra_vst_dirs if d and os.path.isdir(d)] for d in dirs: try: r = subprocess.run([exe, "--scan", d], capture_output=True, text=True, timeout=600) except Exception: continue if r.returncode != 0: continue try: data = json.loads(r.stdout[r.stdout.find("{"):]) except Exception: continue for p in data.get("plugins", []): path = p.get("path", "") if not path: continue is_fx = bool(p.get("is_fx")) is_instr = bool(p.get("is_instrument")) name = p.get("name") or os.path.splitext(os.path.basename(path))[0] entries.append({ "id": name, "name": name, "path": path, "type": p.get("type", "VST3"), "is_fx": is_fx, "is_instrument": is_instr, "is_unknown": not is_fx and not is_instr, "category": _classify_fx_category(name, path), }) seen, uniq = set(), [] for e in entries: if e["path"] in seen: continue seen.add(e["path"]) uniq.append(e) self._fx_scan_all = uniq return uniq @staticmethod def midi_events_to_messages(midi_events: list, bpm: float, sr: int, bank: int = None, program: int = None) -> list: beat_duration_sec = 60.0 / max(30.0, bpm) # MIDI message = (bytes raw MIDI, timestamp_seconds). bytes là raw MIDI # event: 0x9n note_on, 0x8n note_off, 0xB0 control_change, 0xC0 program_change messages = [] if bank is not None: messages.append((bytes([0xB0, 0, int(bank) & 0x7F]), 0.0)) if program is not None: messages.append((bytes([0xC0, int(program) & 0x7F]), 0.0)) for ev in midi_events: note = int(ev.get("note", 60)) & 0x7F velocity = max(0, min(127, int(ev.get("velocity", 100)))) start_beat = float(ev.get("start_beat", 0.0)) dur_beats = float(ev.get("duration_beats", 1.0)) start_sec = start_beat * beat_duration_sec end_sec = (start_beat + dur_beats) * beat_duration_sec messages.append((bytes([0x90, note, velocity]), start_sec)) messages.append((bytes([0x80, note, 0]), end_sec)) 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): # Phase 4 (PLAN_REPLACE_PEDALBOARD_NATIVE_BRIDGE.md §3.4): .dspreset là # format riêng của DecentSampler (không phải VST3 state) — spike đã fail # cả trên pedalboard (container: "failed to load data from preset file"). # pedalboard GPL-3.0 bị gỡ → chặn Pianobook + hướng dẫn convert. raise NotImplementedError( "Pianobook .dspreset không còn được hỗ trợ (pedalboard GPL-3.0 đã gỡ). " "Chuyển đổi: mở preset trong DecentSampler → save .vstpreset (VST3 state) " "rồi upload .vstpreset qua web UI — native_bridge đọc được." ) # ── Preset bridge (Carla ↔ pedalboard) ───────────────────────────────────── # Carla (chạy ngoài, user tự cài — GPL-2.0+ nên app KHÔNG bundle/nhúng) dùng # để mở native GUI VSTi + xuất file preset (.vstpreset từ nút Save của plugin). # File preset nằm trong thư viện storage/presets (upload qua web UI hoặc picker # local trên Windows) → render_engine tải qua apply_preset_to_plugin() khi render. PRESET_EXTENSIONS = (".vstpreset", ".fxp", ".fxb", ".dspreset") def preset_library_dir() -> str: from app.config import settings as _st d = os.path.join(_st.STORAGE_DIR, "presets") try: os.makedirs(d, exist_ok=True) except Exception: pass return d def resolve_preset_path(preset_id_or_path: str) -> str: """preset_id (tên file trong thư viện) hoặc đường dẫn tuyệt đối → path. Trả '' nếu không tìm thấy. Chống path traversal: chỉ chấp nhận tên file (không chứa separator) hoặc đường dẫn tuyệt đối tồn tại.""" if not preset_id_or_path: return "" p = preset_id_or_path # Đường dẫn tuyệt đối / tương đối tồn tại → dùng thẳng if os.path.isfile(p): return p # id dạng tên file trong thư viện (uuid + ext) if os.path.basename(p) == p: cand = os.path.join(preset_library_dir(), p) if os.path.isfile(cand): return cand # Không có ext → quét theo prefix (uuid.idx → uuid.vstpreset) try: for f in os.listdir(preset_library_dir()): if f.lower().endswith(PRESET_EXTENSIONS) and f.startswith(p + "."): return os.path.join(preset_library_dir(), f) except Exception: pass return "" def resolve_plugin_path(name_or_path: str, ext: str = "") -> str: """E3: resolve path thật của plugin/soundfont cho Native Bridge (E2). ext: '.vst3' | '.vst2' | '.sf2' | '.sf3' | '.sfz' — filter phần mở rộng khi quét plugin_dirs. Trả '' nếu không tìm thấy. Chống path traversal: chỉ tên file (không separator) hoặc path tồn tại được chấp nhận.""" if not name_or_path: return "" p = name_or_path if os.path.isfile(p): return p base = os.path.basename(p.replace("\\", "/")) base_noext = os.path.splitext(base)[0].lower() want_ext = ext.lower() if ext else None dirs = [] try: dirs = list(_load_user_plugin_dirs()) or [] except Exception: pass from app.config import settings as _st dirs += [_st.VST_DIR, _st.SOUNDFONT_DIR] for base_dir in dirs: if not os.path.isdir(base_dir): continue for root, _, files in os.walk(base_dir): for fn in files: if want_ext and not fn.lower().endswith(want_ext): continue if fn.lower() == base.lower() or os.path.splitext(fn)[0].lower() == base_noext: return os.path.join(root, fn) return "" def apply_preset_to_plugin(plugin, preset_id=None, preset_path=None, preset_data_b64=None) -> bool: """Gán preset lên plugin VSTi: bytes nhúng (base64 .vstpreset) ưu tiên, sau đó preset_id (thư viện), sau preset_path (file). Trả True nếu áp dụng được; KHÔNG raise (render engine chỉ log warning).""" if plugin is None: return False try: if preset_data_b64: import base64 raw = base64.b64decode(preset_data_b64) if hasattr(plugin, "preset_data"): plugin.preset_data = raw # bytes dạng .vstpreset (VST3) return True p = resolve_preset_path(preset_id or "") or resolve_preset_path(preset_path or "") if p and hasattr(plugin, "load_preset"): plugin.load_preset(p) # .vstpreset (VST3) / .dspreset (DecentSampler) return True except Exception: return False return False