# 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))