FIX: Carla sử dụng bridge
This commit is contained in:
@@ -10,6 +10,7 @@ from app.core.vst_engine import (
|
||||
DecentSamplerManager,
|
||||
HAS_PEDALBOARD,
|
||||
HAS_PYFLUIDSYNTH,
|
||||
apply_preset_to_plugin,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -223,6 +224,21 @@ class PythonRenderEngine:
|
||||
)
|
||||
elif vst and HAS_PEDALBOARD:
|
||||
from pedalboard import Pedalboard
|
||||
# Preset bridge Carla → pedalboard: preset_id
|
||||
# (thư viện storage/presets), preset_path (file)
|
||||
# hoặc preset_data (base64 .vstpreset nhúng trong
|
||||
# project). Cùng sample rate → âm render = âm đã
|
||||
# chỉnh trong Carla.
|
||||
try:
|
||||
se2 = track.get("synth_engine", {}) or {}
|
||||
apply_preset_to_plugin(
|
||||
vst,
|
||||
preset_id=se2.get("preset_id") or track.get("preset_id"),
|
||||
preset_path=se2.get("preset_path") or track.get("preset_path"),
|
||||
preset_data_b64=se2.get("preset_data") or track.get("preset_data"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[RenderEngine] Preset apply failed: %s", e)
|
||||
midi_messages = PluginManager.midi_events_to_messages(
|
||||
midi_events, bpm, self.sample_rate,
|
||||
bank=soundfont_bank, program=soundfont_program
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
# SonicForge Runtime Profile — phát hiện môi trường chạy (desktop Windows /
|
||||
# docker headless) và khả năng của máy, để app TỰ CHỌN cách xử lý:
|
||||
# - Windows desktop (server + client cùng 1 máy): Carla local để preview +
|
||||
# chỉnh preset → pedalboard render (Hướng A)
|
||||
# - Docker / Linux headless (server + browser UI): soundfont + VSTi mở được
|
||||
# từ storage mount; preset upload qua browser; không có GUI local.
|
||||
#
|
||||
# Ưu tiên: SF_RUNTIME env override > heuristic (platform + display + docker).
|
||||
# Module CHỈ dùng stdlib + app.config (không kéo pedalboard/numpy) để import
|
||||
# nhẹ và hoạt động trong mọi tiến trình (web/worker/celery).
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import functools
|
||||
|
||||
from app.config import settings
|
||||
|
||||
SF_RUNTIME_ENV = "SF_RUNTIME" # auto | desktop | headless
|
||||
SF_DOCKER_ENV = "SF_DOCKER" # 1 = chạy trong container Docker
|
||||
CARLA_CONFIG_FILE = "carla_path.json" # storage/carla_path.json — user định vị
|
||||
# Bản Carla portable (zip) có thể giải nén ở BẤT KỲ ĐÂU — PATH/Program Files
|
||||
# không đủ. User tự chọn thư mục chứa carla.exe qua Plugin Manager → lưu file
|
||||
# này (ưu tiên cao nhất khi detect), kèm tìm kiếm nông Downloads/Desktop.
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
return os.name == "nt"
|
||||
|
||||
|
||||
def _is_macos() -> bool:
|
||||
return sys.platform == "darwin"
|
||||
|
||||
|
||||
def _is_linux() -> bool:
|
||||
return not _is_windows() and not _is_macos()
|
||||
|
||||
|
||||
def _in_docker() -> bool:
|
||||
if os.environ.get(SF_DOCKER_ENV) == "1":
|
||||
return True
|
||||
# Marker chuẩn của Docker (không tồn tại trên máy host thường)
|
||||
try:
|
||||
return os.path.exists("/.dockerenv")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _has_display() -> bool:
|
||||
"""Có môi trường GUI hiển thị được hay không.
|
||||
|
||||
Windows/macOS luôn có (desktop). Linux cần biến DISPLAY (X11) — nếu chạy
|
||||
Docker không có DISPLAY → headless."""
|
||||
if _is_windows() or _is_macos():
|
||||
return True
|
||||
return bool(os.environ.get("DISPLAY"))
|
||||
|
||||
|
||||
def _tauri_bridge_ready() -> bool:
|
||||
"""App desktop Tauri viết marker file lúc setup (xem plugins.py
|
||||
_pick_dir_via_tauri_bridge) — dùng để nhận diện bản desktop app."""
|
||||
try:
|
||||
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||
return os.path.exists(os.path.join(root, "SonicForgeDAW", "ipc", "tauri_bridge_ready"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _carla_config_path() -> str:
|
||||
return os.path.join(settings.STORAGE_DIR, CARLA_CONFIG_FILE)
|
||||
|
||||
|
||||
def get_configured_carla() -> str:
|
||||
"""Đường dẫn carla.exe do USER tự định vị (lưu trong storage/carla_path.json).
|
||||
|
||||
File lưu đường dẫn tới carla.exe (đã resolve) hoặc thư mục chứa — nếu là
|
||||
thư mục, tìm carla.exe bên trong (độ sâu ≤ 2). Đây là cách bắt buộc có cho
|
||||
bản Carla portable (zip) giải nén ở vị trí bất kỳ, không cài đặt/PATH."""
|
||||
try:
|
||||
with open(_carla_config_path(), "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
return ""
|
||||
p = (data.get("carla_path") or "").strip()
|
||||
if not p:
|
||||
return ""
|
||||
if os.path.isfile(p) and os.path.basename(p).lower() in ("carla.exe", "carla-single.exe", "carla"):
|
||||
return p
|
||||
if os.path.isdir(p):
|
||||
try:
|
||||
for root, dirs, files in os.walk(p):
|
||||
depth = root[len(p):].count(os.sep)
|
||||
if depth >= 2:
|
||||
dirs[:] = []
|
||||
continue
|
||||
for f in files:
|
||||
if f.lower() == "carla.exe":
|
||||
return os.path.join(root, f)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def save_carla_path(path: str) -> str:
|
||||
"""Lưu vị trí Carla do user chọn (Plugin Manager → Định vị Carla...).
|
||||
|
||||
Chấp nhận: đường dẫn tới carla.exe, hoặc thư mục chứa carla.exe (portable
|
||||
zip). Resolve về đường dẫn exe hợp lệ → ghi config → xóa cache detect.
|
||||
Trả đường dẫn exe, hoặc '' nếu không hợp lệ."""
|
||||
path = (path or "").strip().strip('"').strip()
|
||||
exe = ""
|
||||
if os.path.isfile(path) and os.path.basename(path).lower() in ("carla.exe", "carla-single.exe", "carla"):
|
||||
exe = path
|
||||
elif os.path.isdir(path):
|
||||
try:
|
||||
for root, dirs, files in os.walk(path):
|
||||
depth = root[len(path):].count(os.sep)
|
||||
if depth >= 2:
|
||||
dirs[:] = []
|
||||
continue
|
||||
for f in files:
|
||||
if f.lower() == "carla.exe":
|
||||
exe = os.path.join(root, f)
|
||||
break
|
||||
if exe:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not exe:
|
||||
return ""
|
||||
try:
|
||||
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||
with open(_carla_config_path(), "w", encoding="utf-8") as f:
|
||||
json.dump({"carla_path": exe, "carla_exe": os.path.basename(exe)}, f, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
return ""
|
||||
# Xóa cache detect/find_carla để capabilities phản ánh ngay
|
||||
invalidate_carla_cache()
|
||||
return exe
|
||||
|
||||
|
||||
def _carla_from_registry() -> str:
|
||||
"""Windows: Carla cài qua installer có thể ghi registry (best-effort)."""
|
||||
if not _is_windows():
|
||||
return ""
|
||||
try:
|
||||
import winreg
|
||||
for hive, key in (
|
||||
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Carla"),
|
||||
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Carla"),
|
||||
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Carla"),
|
||||
):
|
||||
try:
|
||||
with winreg.OpenKey(hive, key) as k:
|
||||
val, _ = winreg.QueryValueEx(k, "InstallPath")
|
||||
p = os.path.join(str(val), "carla.exe")
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _find_carla_exe_bounded(base: str, max_depth: int = 3) -> str:
|
||||
"""Quét nông tìm carla.exe (bản portable giải nén thường nằm Downloads/
|
||||
Desktop/Documents). Giới hạn độ sâu + bỏ qua thư mục lớn — KHÔNG quét
|
||||
toàn ổ đĩa."""
|
||||
if not base or not os.path.isdir(base):
|
||||
return ""
|
||||
skip = ("node_modules", ".cache", "AppData", ".git", "venv", ".venv",
|
||||
"__pycache__", "$RECYCLE.BIN", "Windows", "Program Files",
|
||||
"Program Files (x86)")
|
||||
try:
|
||||
for root, dirs, files in os.walk(base):
|
||||
depth = root[len(base):].count(os.sep) if root != base else 0
|
||||
if depth >= max_depth:
|
||||
dirs[:] = []
|
||||
continue
|
||||
for f in files:
|
||||
if f.lower() == "carla.exe":
|
||||
return os.path.join(root, f)
|
||||
dirs[:] = [d for d in dirs if d not in skip and not d.startswith(".")]
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _carla_from_shallow_search() -> str:
|
||||
"""Windows: tìm carla.exe trong Downloads/Desktop/Documents/Home."""
|
||||
if not _is_windows():
|
||||
return ""
|
||||
home = os.environ.get("USERPROFILE") or os.path.expanduser("~")
|
||||
bases = []
|
||||
for sub in ("Downloads", "Desktop", "Documents"):
|
||||
bases.append(os.path.join(home, sub))
|
||||
bases.append(home)
|
||||
for base in bases:
|
||||
hit = _find_carla_exe_bounded(base)
|
||||
if hit:
|
||||
return hit
|
||||
return ""
|
||||
|
||||
|
||||
def _carla_candidates() -> list:
|
||||
"""Các vị trí Carla — theo thứ tự ưu tiên:
|
||||
1) user định vị (config file — bản portable bắt buộc dùng cách này)
|
||||
2) PATH (shutil.which)
|
||||
3) registry (Windows, nếu cài qua installer)
|
||||
4) thư mục cài đặt chuẩn (Program Files...)
|
||||
5) quét nông Downloads/Desktop/Documents (Windows) / AppImage (Linux)"""
|
||||
cands = []
|
||||
configured = get_configured_carla()
|
||||
if configured:
|
||||
cands.append(configured)
|
||||
try:
|
||||
which = shutil.which("carla")
|
||||
if which:
|
||||
cands.append(which)
|
||||
except Exception:
|
||||
pass
|
||||
reg = _carla_from_registry()
|
||||
if reg:
|
||||
cands.append(reg)
|
||||
try:
|
||||
if _is_windows():
|
||||
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
lpf = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||||
la = os.environ.get("LOCALAPPDATA", "")
|
||||
for base in (pf, lpf, la):
|
||||
for rel in ("Carla\\carla.exe", "Carla\\bin\\carla.exe", "Programs\\Carla\\carla.exe"):
|
||||
p = os.path.join(base, rel) if base else ""
|
||||
if p and os.path.isfile(p):
|
||||
cands.append(p)
|
||||
elif _is_linux():
|
||||
home = os.path.expanduser("~")
|
||||
for p in ("/usr/bin/carla", "/usr/local/bin/carla", "/opt/carla/carla"):
|
||||
if os.path.isfile(p):
|
||||
cands.append(p)
|
||||
try:
|
||||
for f in os.listdir(home):
|
||||
if f.lower().startswith("carla") and f.lower().endswith(".appimage"):
|
||||
cands.append(os.path.join(home, f))
|
||||
except Exception:
|
||||
pass
|
||||
elif _is_macos():
|
||||
for p in ("/Applications/Carla.app/Contents/MacOS/Carla", "/Applications/Carla.app/Contents/MacOS/carla"):
|
||||
if os.path.isfile(p):
|
||||
cands.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
shallow = _carla_from_shallow_search()
|
||||
if shallow:
|
||||
cands.append(shallow)
|
||||
# Dedup giữ thứ tự
|
||||
seen, out = set(), []
|
||||
for c in cands:
|
||||
if c not in seen:
|
||||
seen.add(c)
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def find_carla() -> str:
|
||||
"""Đường dẫn Carla đầu tiên tìm thấy, hoặc '' nếu chưa có.
|
||||
|
||||
Lưu ý license: Carla GPL-2.0+ — app KHÔNG bundle/nhúng, chỉ spawn tiến
|
||||
trình ngoài (user tự cài/giải nén) → không dính copyleft."""
|
||||
for c in _carla_candidates():
|
||||
return c
|
||||
return ""
|
||||
|
||||
|
||||
def invalidate_carla_cache():
|
||||
"""Xóa cache detect/find_carla — gọi sau khi user định vị Carla mới."""
|
||||
find_carla.cache_clear()
|
||||
detect.cache_clear()
|
||||
|
||||
|
||||
def _detect_platform() -> str:
|
||||
if _is_windows():
|
||||
return "windows"
|
||||
if _is_macos():
|
||||
return "darwin"
|
||||
return "linux"
|
||||
|
||||
|
||||
def _detect_runtime() -> str:
|
||||
"""auto → desktop nếu có GUI (Windows/macOS luôn; Linux cần DISPLAY và
|
||||
không phải docker), ngược lại headless. SF_RUNTIME override thắng."""
|
||||
override = (os.environ.get(SF_RUNTIME_ENV) or "auto").strip().lower()
|
||||
if override in ("desktop", "headless"):
|
||||
return override
|
||||
if _is_windows() or _is_macos():
|
||||
return "desktop"
|
||||
if _in_docker() or not _has_display():
|
||||
return "headless"
|
||||
return "desktop"
|
||||
|
||||
|
||||
def default_vst_dirs() -> list:
|
||||
"""Thư mục VST mặc định theo platform (vẫn ưu tiên env VST_DIR).
|
||||
|
||||
Windows: thư mục chuẩn VST3 của hệ thống; user thường khai báo thêm qua
|
||||
Plugins Manager (plugin_dirs.json)."""
|
||||
if settings.VST_DIR and settings.VST_DIR != "/opt/daw_engine/vst3":
|
||||
return [settings.VST_DIR]
|
||||
if _is_windows():
|
||||
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
return [os.path.join(pf, "Common Files", "VST3"), os.path.join(pf, "VSTPlugins")]
|
||||
if _is_macos():
|
||||
return ["/Library/Audio/Plug-Ins/VST3", os.path.expanduser("~/Library/Audio/Plug-Ins/VST3")]
|
||||
return ["/opt/daw_engine/vst3", os.path.expanduser("~/.vst3")]
|
||||
|
||||
|
||||
def default_soundfont_dirs() -> list:
|
||||
if settings.SOUNDFONT_DIR and settings.SOUNDFONT_DIR != "/opt/daw_engine/soundfonts":
|
||||
return [settings.SOUNDFONT_DIR]
|
||||
if _is_windows():
|
||||
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||
return [os.path.join(root, "SonicForgeDAW", "soundfonts")]
|
||||
if _is_macos():
|
||||
return [os.path.expanduser("~/Music/SonicForgeDAW/soundfonts")]
|
||||
return ["/opt/daw_engine/soundfonts", os.path.expanduser("~/.sf2")]
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def detect() -> dict:
|
||||
"""Detect 1 lần (cache toàn cục) — kết quả bất biến trong 1 tiến trình."""
|
||||
platform = _detect_platform()
|
||||
runtime = _detect_runtime()
|
||||
carla = find_carla() if runtime == "desktop" else ""
|
||||
return {
|
||||
"platform": platform,
|
||||
"runtime": runtime, # "desktop" | "headless"
|
||||
"docker": _in_docker(),
|
||||
"has_display": _has_display(),
|
||||
"tauri_bridge": _tauri_bridge_ready(),
|
||||
"carla_path": carla,
|
||||
"carla_local": bool(carla), # chỉ có ý nghĩa khi runtime=desktop
|
||||
"vst_render": _vst_render_available(),
|
||||
"default_vst_dirs": default_vst_dirs(),
|
||||
"default_soundfont_dirs": default_soundfont_dirs(),
|
||||
"preset_dir": os.path.join(settings.STORAGE_DIR, "presets"),
|
||||
}
|
||||
|
||||
|
||||
def _vst_render_available() -> bool:
|
||||
"""pedalboard có sẵn không (dùng find_spec — KHÔNG import pedalboard để
|
||||
tránh kéo JUCE lib; giống pattern _module_available trong vst_engine)."""
|
||||
import importlib.util
|
||||
try:
|
||||
return importlib.util.find_spec("pedalboard") is not None
|
||||
except (ImportError, AttributeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def capabilities() -> dict:
|
||||
"""Capabilities API — frontend gọi 1 lần lúc boot để bật/tắt tính năng."""
|
||||
d = detect()
|
||||
features = {
|
||||
"carla_local": d["carla_local"],
|
||||
"carla_path": d["carla_path"],
|
||||
"preset_upload": True, # cả 2 mode đều upload preset được
|
||||
"vst_render": d["vst_render"],
|
||||
"tauri_bridge": d["tauri_bridge"],
|
||||
# preview VSTi: quick_render (pedalboard render clip ngắn — âm thật,
|
||||
# giống export) là chuẩn cho cả 2 mode; không có realtime trong web UI.
|
||||
"preview_mode": "quick_render" if d["vst_render"] else "wasm",
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"runtime": d["runtime"],
|
||||
"platform": d["platform"],
|
||||
"docker": d["docker"],
|
||||
"features": features,
|
||||
"default_dirs": {
|
||||
"vst": d["default_vst_dirs"],
|
||||
"soundfont": d["default_soundfont_dirs"],
|
||||
"preset": d["preset_dir"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def is_desktop() -> bool:
|
||||
return detect()["runtime"] == "desktop"
|
||||
|
||||
|
||||
def is_headless() -> bool:
|
||||
return detect()["runtime"] == "headless"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# python -m app.core.runtime → in capabilities để kiểm tra detect
|
||||
import json
|
||||
print(json.dumps(capabilities(), ensure_ascii=False, indent=2))
|
||||
@@ -408,3 +408,70 @@ class DecentSamplerManager:
|
||||
os.chdir(cwd_before)
|
||||
|
||||
return plugin
|
||||
|
||||
|
||||
# ── 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 apply_preset_to_plugin(plugin, preset_id=None, preset_path=None, preset_data_b64=None) -> bool:
|
||||
"""Gán preset lên plugin pedalboard: 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user