399 lines
14 KiB
Python
399 lines
14 KiB
Python
# 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))
|