FIX: Carla sử dụng bridge

This commit is contained in:
2026-08-10 07:57:26 +07:00
parent fbaac1f673
commit aa32272f36
17 changed files with 1372 additions and 34 deletions
+9
View File
@@ -23,3 +23,12 @@ STORAGE_DIR=/app/app/storage
VST_DIR=/home/locpham/daw_assets/vst3
SOUNDFONT_DIR=/home/locpham/daw_assets/soundfonts
PIANOBK_DIR=/home/locpham/daw_assets/pianobook
# ── Runtime (tự phát hiện môi trường) ──
# auto (mặc định): Windows/macOS → desktop; Linux không DISPLAY hoặc docker → headless.
# desktop: server + client cùng 1 máy (bật nút "Mở trong Carla" nếu có Carla local)
# headless: server docker + browser UI (soundfont + VSTi mở được từ storage mount;
# preset chỉnh trên máy khác → upload .vstpreset qua web UI)
SF_RUNTIME=auto
# Ép nhận diện Docker (thường tự detect qua /.dockerenv; đặt =1 nếu cần)
SF_DOCKER=1
+131 -1
View File
@@ -1,10 +1,12 @@
import os, sys, uuid, json, tempfile, subprocess, time as _time
import numpy as np
import soundfile as sf
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional, Any
from app.config import settings
from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH
from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH, apply_preset_to_plugin
from app.core.render_engine import PythonRenderEngine
from app.core.soundfont_inspector import SoundFontInspector
from app.core.soundfont_converter import SoundFontConverter
@@ -446,6 +448,134 @@ class RenderRequest(BaseModel):
output_filename: Optional[str] = "render_output.wav"
class OpenInCarlaRequest(BaseModel):
"""Mở native GUI của VSTi trong Carla (chỉ khả dụng khi runtime=desktop
và Carla được cài trên cùng máy — tự phát hiện qua runtime profile)."""
plugin_name: Optional[str] = None
plugin_path: Optional[str] = None
class PreviewRequest(BaseModel):
"""Quick-render preview: pedalboard render clip ngắn bằng ĐÚNG plugin +
preset (cùng code path với export) → trả wav để browser phát.
Âm preview = âm export (khác Preview Synth WASM hiện tại)."""
instrument_id: str
notes: list = []
bpm: float = 120.0
sample_rate: int = 44100
soundfont_bank: Optional[int] = 0
soundfont_program: Optional[int] = 0
preset_id: Optional[str] = None
preset_path: Optional[str] = None
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng)
@router.post("/open-in-carla")
async def open_in_carla(req: OpenInCarlaRequest, current_user: dict = Depends(get_current_user)):
"""Mở Carla (native GUI host) trên máy hiện tại — Windows desktop mode.
Carla là app ngoài do user tự cài (GPL-2.0+ → không bundle/nhúng). App chỉ
spawn tiến trình; user chỉnh preset trong GUI rồi Save → .vstpreset → upload
vào thư viện preset → gán vào track → render engine tải preset tương ứng."""
enforce_password_changed(current_user)
from app.core.runtime import find_carla
carla = find_carla()
if not carla:
raise HTTPException(
status_code=409,
detail="Không tìm thấy Carla trên máy này. Hãy giải nén bản Carla "
"portable (zip, miễn phí) từ https://github.com/falkTX/Carla/releases, "
"rồi vào Plugin Manager → Carla Bridge → Định vị Carla... để chọn "
"thư mục chứa carla.exe (bản portable không dùng PATH).",
)
plugin_path = req.plugin_path or ""
if not plugin_path and req.plugin_name:
try:
pm = PluginManager()
plugins = pm._scan_plugins()
if req.plugin_name in plugins:
plugin_path = plugins[req.plugin_name]
except Exception:
plugin_path = ""
cmd = [carla]
if plugin_path:
# carla-single: mở thẳng 1 plugin thành app standalone có native GUI
single = os.path.join(
os.path.dirname(carla),
"carla-single" + (".exe" if os.name == "nt" else ""),
)
if os.path.isfile(single):
cmd = [single, plugin_path]
try:
cwd = os.path.dirname(carla) or None
subprocess.Popen(cmd, cwd=cwd, close_fds=os.name != "nt")
return {
"success": True,
"started": True,
"carla_path": carla,
"plugin_path": plugin_path,
"cmd": cmd,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Không mở được Carla: {e}")
@router.post("/preview")
async def preview_instrument(req: PreviewRequest):
"""Quick-render preview VSTi (âm thật, cùng code path với export)."""
if not HAS_PEDALBOARD:
raise HTTPException(status_code=501, detail="pedalboard không khả dụng trên máy này")
if not req.notes:
raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để preview")
try:
pm = PluginManager()
vst = pm.load_vst(req.instrument_id)
if vst is None:
raise HTTPException(status_code=404, detail=f"Không tìm thấy VSTi: {req.instrument_id}")
apply_preset_to_plugin(
vst,
preset_id=req.preset_id,
preset_path=req.preset_path,
preset_data_b64=req.preset_data,
)
from pedalboard import Pedalboard
midi_events = []
for n in req.notes:
midi_events.append({
"note": int(n.get("pitch", 60)),
"start_beat": float(n.get("start_beat", 0)),
"duration_beats": float(n.get("duration_beats", 1)),
"velocity": int(float(n.get("velocity", 0.8)) * 127),
})
midi_messages = PluginManager.midi_events_to_messages(
midi_events, req.bpm, req.sample_rate,
bank=req.soundfont_bank, program=req.soundfont_program,
)
total_needed = 0
beat_sec = 60.0 / max(30.0, req.bpm)
for ev in midi_events:
end_sec = (ev["start_beat"] + ev["duration_beats"]) * beat_sec
if int(end_sec * req.sample_rate) > total_needed:
total_needed = int(end_sec * req.sample_rate)
total_needed = max(total_needed, 1024)
silent = np.zeros((2, total_needed), dtype=np.float32)
board = Pedalboard([vst])
buf = board(silent, sample_rate=req.sample_rate, midi_messages=midi_messages)
fname = f"preview_{uuid.uuid4().hex[:10]}.wav"
out_path = os.path.join(settings.PROCESSED_DIR, fname)
sf.write(out_path, buf.T, req.sample_rate)
return {
"success": True,
"url": f"/static/audio/processed/{fname}",
"path": out_path,
"duration_sec": round(buf.shape[1] / float(req.sample_rate), 3),
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Preview thất bại: {e}")
@router.post("/render")
async def render_project(
req: RenderRequest,
+129
View File
@@ -0,0 +1,129 @@
# SonicForge Preset Library API — thư viện preset VST3 (.vstpreset) nằm trong
# storage/presets (mount qua volume trong docker; thư mục storage trên Windows).
#
# Vai trò: cầu nối Carla → pedalboard. User chỉnh preset trong Carla (native
# GUI) → xuất .vstpreset → upload vào thư viện → gán vào track (preset_id trong
# synth_engine) → render_engine tải qua load_preset → âm render = âm đã chỉnh.
import os
import uuid
import json
import time
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
from fastapi.responses import FileResponse
from typing import Optional
from app.core.vst_engine import preset_library_dir, PRESET_EXTENSIONS
from app.api.v1.auth import get_current_user, enforce_password_changed
router = APIRouter()
def _safe_preset_path(preset_id: str) -> str:
"""Chống path traversal: chỉ cho phép tên file (không chứa separator)."""
if not preset_id or os.path.basename(preset_id) != preset_id:
return ""
d = preset_library_dir()
p = os.path.join(d, preset_id)
if os.path.isfile(p) and os.path.dirname(os.path.abspath(p)) == os.path.abspath(d):
return p
return ""
@router.get("")
async def list_presets():
"""Danh sách preset trong thư viện (public — frontend cần trước login)."""
d = preset_library_dir()
items = []
try:
names = sorted(os.listdir(d))
except Exception:
names = []
for f in names:
low = f.lower()
if not low.endswith(PRESET_EXTENSIONS):
continue
meta = {}
meta_path = os.path.join(d, os.path.splitext(f)[0] + ".meta")
if os.path.isfile(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as mf:
meta = json.load(mf)
except Exception:
meta = {}
try:
size = os.path.getsize(os.path.join(d, f))
except Exception:
size = 0
items.append({
"id": f,
"name": meta.get("original_name", f),
"plugin_hint": meta.get("plugin_hint", ""),
"size_bytes": size,
"created_at": meta.get("created_at", ""),
})
return {"success": True, "presets": items}
@router.post("/upload")
async def upload_preset(
file: UploadFile = File(...),
plugin_hint: Optional[str] = None,
current_user: dict = Depends(get_current_user),
):
"""Upload preset (.vstpreset / .fxp / .fxb / .dspreset) vào thư viện."""
enforce_password_changed(current_user)
filename = (file.filename or "preset.vstpreset").replace("\\", "/").split("/")[-1]
ext = os.path.splitext(filename)[1].lower()
if ext not in PRESET_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Định dạng preset không hỗ trợ: {ext or '(không có đuôi)'} — hỗ trợ: {', '.join(PRESET_EXTENSIONS)}",
)
contents = await file.read()
if not contents:
raise HTTPException(status_code=400, detail="File rỗng")
d = preset_library_dir()
preset_id = uuid.uuid4().hex + ext
dest = os.path.join(d, preset_id)
try:
with open(dest, "wb") as fh:
fh.write(contents)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Không lưu được preset: {e}")
meta = {
"original_name": filename,
"plugin_hint": plugin_hint or "",
"size_bytes": len(contents),
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
try:
with open(os.path.join(d, os.path.splitext(preset_id)[0] + ".meta"), "w", encoding="utf-8") as mf:
json.dump(meta, mf, ensure_ascii=False, indent=2)
except Exception:
pass
return {"success": True, "preset_id": preset_id, **meta}
@router.get("/{preset_id}/download")
async def download_preset(preset_id: str):
path = _safe_preset_path(preset_id)
if not path:
raise HTTPException(status_code=404, detail="Preset không tồn tại")
return FileResponse(path, filename=preset_id, media_type="application/octet-stream")
@router.delete("/{preset_id}")
async def delete_preset(preset_id: str, current_user: dict = Depends(get_current_user)):
enforce_password_changed(current_user)
path = _safe_preset_path(preset_id)
if not path:
raise HTTPException(status_code=404, detail="Preset không tồn tại")
try:
os.remove(path)
mp = os.path.join(preset_library_dir(), os.path.splitext(preset_id)[0] + ".meta")
if os.path.isfile(mp):
os.remove(mp)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Không xóa được preset: {e}")
return {"success": True, "preset_id": preset_id}
+51
View File
@@ -0,0 +1,51 @@
# SonicForge System API — capabilities: frontend gọi 1 lần lúc boot để biết
# môi trường (desktop Windows / docker headless) và bật/tắt tính năng tương ứng.
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from app.core.runtime import capabilities, save_carla_path
from app.api.v1.auth import get_current_user, enforce_password_changed
router = APIRouter()
@router.get("/capabilities")
async def get_capabilities():
"""Khả năng của môi trường hiện tại (public — cần trước khi đăng nhập).
- runtime: "desktop" (server + client cùng 1 máy Windows/macOS) |
"headless" (docker server + browser UI)
- features.carla_local: có Carla trên máy này → hiện nút "Mở trong Carla"
- features.preset_upload: luôn True (upload .vstpreset qua web UI)
- features.preview_mode: "quick_render" (pedalboard render clip ngắn —
âm thật giống export) | "wasm" (Preview Synth trong browser)
"""
return capabilities()
class CarlaPathRequest(BaseModel):
"""Định vị Carla (bản portable zip không cài đặt/PATH). Chấp nhận đường
dẫn tới carla.exe HOẶC thư mục chứa carla.exe — resolve và lưu config."""
carla_path: str
carla_dir: Optional[str] = None # tương thích ngược: tên cũ của carla_path
@router.post("/carla-path")
async def set_carla_path(req: CarlaPathRequest, current_user: dict = Depends(get_current_user)):
"""Lưu vị trí carla.exe do user chọn (Plugin Manager → Định vị Carla...).
Cần thiết vì bản Carla Windows là bộ file zip portable — không có installer
cũng không dùng biến môi trường PATH, nên heuristic không tìm thấy."""
enforce_password_changed(current_user)
target = (req.carla_path or req.carla_dir or "").strip()
if not target:
raise HTTPException(status_code=400, detail="Thiếu đường dẫn Carla")
exe = save_carla_path(target)
if not exe:
raise HTTPException(
status_code=400,
detail="Không tìm thấy carla.exe trong đường dẫn đã chọn. Hãy chọn "
"thư mục chứa carla.exe (bản portable giải nén) hoặc chính file carla.exe.",
)
return {"success": True, "carla_path": exe, **capabilities()}
+31 -3
View File
@@ -17,6 +17,28 @@ def _storage_dir():
return os.path.join(_app_dir(), "storage")
def _default_vst_dir():
"""Thư mục VST mặc định theo platform (env VST_DIR vẫn thắng).
Windows: thư mục VST3 chuẩn của hệ thống — user có thể thêm thư mục khác
qua Plugins Manager (plugin_dirs.json). Docker/Linux: mount qua compose."""
if os.name == "nt":
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
return os.path.join(pf, "Common Files", "VST3")
if sys.platform == "darwin":
return "/Library/Audio/Plug-Ins/VST3"
return "/opt/daw_engine/vst3"
def _default_soundfont_dir():
if os.name == "nt":
root = os.environ.get("APPDATA") or os.path.expanduser("~")
return os.path.join(root, "SonicForgeDAW", "soundfonts")
if sys.platform == "darwin":
return os.path.expanduser("~/Music/SonicForgeDAW/soundfonts")
return "/opt/daw_engine/soundfonts"
class Settings:
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
@@ -30,8 +52,14 @@ class Settings:
PROCESSED_DIR: str = os.path.join(STORAGE_DIR, "processed")
# Plugin dirs — người dùng khai báo qua .env / docker-compose (Docker)
# hoặc qua Plugins Manager (Windows/macOS, lưu theo user).
VST_DIR: str = os.getenv("VST_DIR", "/opt/daw_engine/vst3")
SOUNDFONT_DIR: str = os.getenv("SOUNDFONT_DIR", "/opt/daw_engine/soundfonts")
# hoặc qua Plugins Manager (Windows/macOS, lưu theo user). Default theo
# platform (Windows: thư mục VST3 chuẩn; Linux: mount compose).
VST_DIR: str = os.getenv("VST_DIR", _default_vst_dir())
SOUNDFONT_DIR: str = os.getenv("SOUNDFONT_DIR", _default_soundfont_dir())
# Thư viện preset VST3 (.vstpreset) — cầu nối Carla → pedalboard.
# Nằm trong storage nên tự động nằm trong volume mount (docker) / thư mục
# storage (Windows desktop).
PRESET_DIR: str = os.path.join(STORAGE_DIR, "presets")
settings = Settings()
+16
View File
@@ -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
+398
View File
@@ -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))
+67
View File
@@ -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
+4
View File
@@ -18,6 +18,8 @@ from app.api.v1.ai_proxy import router as ai_proxy_router
from app.api.v1.ai_presets import router as ai_presets_router
from app.api.v1.plugins import router as plugins_router
from app.api.v1.media import router as media_router
from app.api.v1.system import router as system_router
from app.api.v1.presets import router as presets_router
from app.core.auth import seed_admin
from app.core.soundfont_scanner import SoundFontAutoScanner
@@ -93,6 +95,8 @@ app.include_router(ai_proxy_router, prefix="/api/v1/ai", tags=["ai"])
app.include_router(ai_presets_router, prefix="/api/v1/ai", tags=["ai"])
app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
app.include_router(media_router, prefix="/api/v1/media", tags=["media"])
app.include_router(system_router, prefix="/api/v1/system", tags=["system"])
app.include_router(presets_router, prefix="/api/v1/presets", tags=["presets"])
@app.get("/health")
+190 -11
View File
@@ -5279,7 +5279,7 @@ const AIConfigModal = ({
}, loading ? 'Đang lưu...' : 'Lưu Cấu Hình AI'))))));
};
const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
const PluginManagerModal = ({ isOpen, onClose, pluginsData, onInsertInstrument }) => {
if (!isOpen) return null;
const [localData, setLocalData] = React.useState(pluginsData);
const [sfUploadStatus, setSfUploadStatus] = React.useState('');
@@ -5288,6 +5288,12 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
const [pmScanning, setPmScanning] = React.useState(false);
const [pmScanResult, setPmScanResult] = React.useState('');
const [pmScanData, setPmScanData] = React.useState(null); // { vst_found, soundfonts }
// Instrument bên trong mi soundfont (expand) "Chèn vào Synth" qua onInsertInstrument
const [pmSfExpanded, setPmSfExpanded] = React.useState({});
const [pmSfInstruments, setPmSfInstruments] = React.useState({});
const [pmSfLoading, setPmSfLoading] = React.useState({});
// Force re-render sau khi đnh v Carla (capabilities đi)
const [pmCarlaVersion, setPmCarlaVersion] = React.useState(0);
React.useEffect(() => {
if (isOpen) {
window.SonicAPI.listPlugins()
@@ -5365,6 +5371,51 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
const removePluginDir = (dir) => {
setPmDirs(prev => prev.filter(d => d !== dir));
};
// Expand 1 soundfont đc danh sách instrument (bank/program/name) bên trong
// qua API soundfont-instruments/{id} hin th nút "Chèn vào Synth".
const toggleSfInstruments = async (sf) => {
const baseId = String(sf.id || '').replace('sf_', '');
setPmSfExpanded(prev => ({ ...prev, [baseId]: !prev[baseId] }));
if (!pmSfInstruments[baseId] && !pmSfLoading[baseId]) {
setPmSfLoading(prev => ({ ...prev, [baseId]: true }));
try {
const r = await window.SonicAPI.listSoundfontInstruments(baseId);
setPmSfInstruments(prev => ({ ...prev, [baseId]: (r && r.presets) || [] }));
} catch (e) {
setPmSfInstruments(prev => ({ ...prev, [baseId]: [] }));
} finally {
setPmSfLoading(prev => ({ ...prev, [baseId]: false }));
}
}
};
// Đnh v Carla.exe bn Windows là zip portable: KHÔNG cài đt, KHÔNG dùng
// biến môi trưng PATH nên heuristic không tìm thy user t chn thư mc
// cha carla.exe (folder picker native) lưu config phía server.
const locateCarla = async () => {
try {
let picked = null;
try {
const d = await window.SonicAPI.pickPluginDir();
if (d && typeof d.path === 'string' && d.path) picked = d.path;
} catch (e) { /* fallthrough */ }
if (!picked && window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke) {
try {
const sel = await window.__TAURI__.core.invoke('plugin:dialog|open', { options: { directory: true, multiple: false } });
if (typeof sel === 'string' && sel) picked = sel;
} catch (e) { /* fallthrough */ }
}
if (!picked) { showToast('Không mở được hộp thoại chọn thư mục', 'error'); return; }
const r = await window.SonicAPI.setCarlaPath(picked);
if (r && r.success && r.carla_path) {
window.SonicRuntime.capabilities = r;
document.documentElement.dataset.carla = r.features && r.features.carla_local ? '1' : '0';
setPmCarlaVersion(v => v + 1);
showToast('Đã định vị Carla: ' + r.carla_path, 'success');
} else {
showToast('Không tìm thấy carla.exe trong thư mục đã chọn', 'error');
}
} catch (err) { showToast('Lỗi định vị Carla: ' + (err.message || err), 'error'); }
};
// Lưu dirs (user override) + scan c 2 thư mc refresh list + catalog.
const saveAndScanDirs = async () => {
setPmScanning(true);
@@ -5545,10 +5596,18 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
) :
(localData.soundfonts?.length === 0 ?
React.createElement('div', { className: 'flex items-center justify-center h-32 text-zinc-500 text-xs' }, 'No SoundFonts found. Upload one below.') :
localData.soundfonts.map((sf, i) =>
React.createElement('div', {
localData.soundfonts.map((sf, i) => {
const baseId = String(sf.id || '').replace('sf_', '');
const expanded = !!pmSfExpanded[baseId];
const insts = pmSfInstruments[baseId] || [];
const loading = !!pmSfLoading[baseId];
return React.createElement('div', {
key: i,
className: 'flex items-center justify-between bg-[#252525] px-4 py-3 rounded-lg border border-[#333] hover:border-amber-800/50 transition group'
className: 'bg-[#252525] rounded-lg border border-[#333] hover:border-amber-800/50 transition group'
},
React.createElement('div', {
className: 'flex items-center justify-between px-4 py-3 cursor-pointer',
onClick: () => toggleSfInstruments(sf)
},
React.createElement('div', { className: 'flex items-center gap-3' },
React.createElement('div', { className: 'w-8 h-8 rounded bg-amber-900/30 flex items-center justify-center' },
@@ -5556,17 +5615,59 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
),
React.createElement('div', null,
React.createElement('div', { className: 'text-xs font-semibold text-slate-200' }, sf.display || sf.name || sf.id),
React.createElement('div', { className: 'text-[10px] text-zinc-500' }, sf.file || sf.name)
React.createElement('div', { className: 'text-[10px] text-zinc-500' }, (sf.file || sf.name) + (insts.length ? ' — ' + insts.length + ' instruments' : ''))
)
),
React.createElement('div', { className: 'flex items-center gap-2' },
React.createElement('span', { className: 'text-[10px] text-zinc-500' }, expanded ? '▾' : '▸'),
React.createElement('button', {
onClick: () => setSfToDelete(sf),
onClick: (e) => { e.stopPropagation(); setSfToDelete(sf); },
className: 'text-[10px] text-zinc-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition px-2 py-1'
}, 'Delete')
)
)
),
expanded && React.createElement('div', { className: 'border-t border-[#333] px-3 py-1 max-h-40 overflow-y-auto' },
loading ?
React.createElement('p', { className: 'text-[10px] text-zinc-500 italic py-1' }, 'Đang đọc instruments...') :
insts.length === 0 ?
React.createElement('p', { className: 'text-[10px] text-zinc-500 italic py-1' }, 'Không có instrument (SF3 cần chuyển đổi trước)') :
insts.map((p, pi) => React.createElement('div', { key: 'si_' + pi, className: 'flex items-center gap-2 py-1 text-[11px]' },
React.createElement('span', { className: 'text-zinc-500 font-mono w-24 shrink-0 text-[9px]' }, 'B' + (p.bank || 0) + ' P' + (p.program || 0)),
React.createElement('span', { className: 'flex-1 truncate text-zinc-300' }, p.name || ('Program ' + p.program)),
React.createElement('button', {
onClick: () => onInsertInstrument && onInsertInstrument({
instrumentId: 'sf_' + baseId,
bank: p.bank || 0,
program: p.program || 0,
name: p.name || ('Program ' + p.program),
displayName: (sf.display || sf.name || sf.id) + ' — ' + (p.name || ('Program ' + p.program))
}),
className: 'text-[10px] bg-amber-800 hover:bg-amber-700 text-white px-2 py-0.5 rounded transition shrink-0'
}, 'Chèn vào Synth')
))
)
);
})
)
),
// Carla Bridge section (desktop) đnh v carla.exe portable
(window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.runtime === 'desktop') && React.createElement('div', { className: 'pt-4 mt-4 border-t border-[#383838]' },
React.createElement('h4', { className: 'text-xs font-bold text-teal-400 uppercase mb-2' }, 'Carla Bridge (VSTi native GUI)'),
React.createElement('p', { className: 'text-[10px] text-zinc-500 mb-2 break-all' },
(window.SonicRuntime.capabilities.features && window.SonicRuntime.capabilities.features.carla_path) ?
'Đã định vị: ' + window.SonicRuntime.capabilities.features.carla_path :
'Chưa tìm thấy Carla. Bản Windows là zip portable (không cài đặt, không dùng PATH) — nhấn "Định vị Carla..." và chọn thư mục chứa carla.exe.'
),
React.createElement('div', { className: 'flex gap-2' },
React.createElement('button', {
onClick: locateCarla,
className: 'px-3 py-1.5 bg-teal-800 hover:bg-teal-700 text-white text-xs font-semibold rounded transition flex items-center gap-1'
}, React.createElement('i', { 'data-lucide': 'folder-search', className: 'w-3 h-3' }), 'Định vị Carla...'),
(window.SonicRuntime.capabilities.features && window.SonicRuntime.capabilities.features.carla_local) && React.createElement('button', {
onClick: () => { window.SonicAPI.openInCarla().then(function (r) { if (r && r.success) showToast('Đã mở Carla', 'success'); }).catch(function (err) { showToast('Lỗi mở Carla: ' + (err.message || err), 'error'); }); },
className: 'px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-teal-300 text-xs font-semibold rounded transition flex items-center gap-1'
}, React.createElement('i', { 'data-lucide': 'play', className: 'w-3 h-3' }), 'Mở Carla')
)
),
// Plugin directories section (folder picker + save + scan)
React.createElement('div', {
@@ -14279,6 +14380,11 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
<div className={`flex items-center gap-1 px-2 py-1 cursor-pointer hover:bg-blue-100 ${!synthInst ? 'bg-slate-200' : ''}`} onClick={() => selectSynthInst(null)}>
<i className="fa-solid fa-ban text-slate-400"></i> None (mặc định)
</div>
{window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.features && window.SonicRuntime.capabilities.features.carla_local && (
<div className="flex items-center gap-1 px-2 py-1 cursor-pointer hover:bg-teal-100 font-semibold text-teal-700 border-t border-[#e0e0e0]" onClick={() => { setSynthOpen(false); window.SonicAPI.openInCarla().then(r => { if (r && r.success) showToast('Đã mở Carla — chỉnh preset rồi Upload trong app', 'success'); }).catch(err => showToast('Lỗi mở Carla: ' + (err.message || err), 'error')); }}>
<i className="fa-solid fa-sliders text-teal-500"></i> 🎛 Carla Bridge (mở Carla.exe)
</div>
)}
{!synthLoading && filteredSynthList && filteredSynthList.map(group => (
<div key={group.sf.id || group.sf.name}>
<div className="px-2 py-1 bg-[#f0f0f0] font-semibold text-slate-600 border-t border-[#e0e0e0] truncate">{group.sf.display || group.sf.name || group.sf.id}</div>
@@ -15520,6 +15626,27 @@ const App = () => {
setAiPrompt(newText);
};
// Gán preset VST3 (.vstpreset t thư vin storage/presets cu ni
// Carla pedalboard) vào track: preset_id nm trong synth_engine render
// engine ti qua load_preset khi render âm render = âm đã chnh trong Carla.
const setTrackPreset = (trackId, presetId) => {
if (!trackId || !presetId) return;
var mt = activeTracksRef.current || tracks;
var cur = null;
for (var ci = 0; ci < mt.length; ci++) { if (mt[ci].id === trackId) { cur = mt[ci]; break; } }
if (!cur) return;
var se = cur.synth_engine || {};
if (!se.plugin_id || String(se.type || '').indexOf('vst') === -1) {
showToast('Chọn VST instrument trước khi gán preset', 'warning');
return;
}
updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, synth_engine: { ...(t.synth_engine || {}), preset_id: presetId } } : t));
setInstrumentDropdownTrackId(null);
setInstrumentDropdownBtnRect(null);
var p = ((window.SonicRuntime && window.SonicRuntime.presets) || []).find(function (x) { return x.id === presetId; });
showToast('Đã gán preset: ' + ((p && p.name) || presetId), 'success');
};
const setTrackInstrumentWithUndo = (trackId, instrumentId, displayName, bankNumber, programNumber) => {
const track = activeTracks.find(t => t.id === trackId);
if (!track) return;
@@ -29802,7 +29929,16 @@ STRICT CONSTRAINTS:
}), /*#__PURE__*/React.createElement(PluginManagerModal, {
isOpen: pluginManagerModalOpen,
onClose: () => setPluginManagerModalOpen(false),
pluginsData: pluginsData
pluginsData: pluginsData,
onInsertInstrument: (inst) => {
// Chèn instrument (soundfont bank/program) vào track đang chn nút Synth
const tid = selectedTrackId || (activeTracks && activeTracks[0] && activeTracks[0].id);
if (tid && inst && inst.instrumentId) {
setTrackInstrumentWithProgram(tid, inst.instrumentId, inst.program, inst.displayName || inst.name, inst.bank);
showToast('Đã chèn nhạc cụ: ' + (inst.displayName || inst.name), 'success');
}
setPluginManagerModalOpen(false);
}
}), /*#__PURE__*/React.createElement(AIPresetModal, {
isOpen: aiPresetModalOpen,
onClose: () => { setAiPresetModalOpen(false); setAiPresetVersion(v => v + 1); }
@@ -29943,6 +30079,14 @@ STRICT CONSTRAINTS:
onClick: () => setTrackInstrumentWithUndo(instrumentDropdownTrackId, null),
className: "w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400"
}, "None (Default Synth)"),
window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.features && window.SonicRuntime.capabilities.features.carla_local ? /*#__PURE__*/React.createElement("button", {
onClick: () => {
setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null);
window.SonicAPI.openInCarla().then(function (r) { if (r && r.success) { showToast('Đã mở Carla — chọn VSTi, chỉnh âm, Save preset (.vstpreset) rồi Upload trong app', 'success'); } }).catch(function (err) { showToast('Lỗi mở Carla: ' + (err.message || err), 'error'); });
},
className: "w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-teal-800 text-teal-300 font-semibold flex items-center justify-between",
title: "Mở Carla.exe trên hệ thống (native GUI VSTi) — không cần scan VST trong app"
}, "\uD83C\uDF9B Carla Bridge (m\u1EDF Carla.exe)", /*#__PURE__*/React.createElement("span", { className: "text-[9px] text-teal-500 shrink-0 ml-1" }, "GUI")) : null,
filteredInstruments.soundfonts.length > 0 && /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 px-3 py-1 mt-1 uppercase font-bold" }, "SoundFonts"),
filteredInstruments.soundfonts.map((sf, i) => /*#__PURE__*/React.createElement("button", {
key: "sfd_" + i,
@@ -29950,11 +30094,46 @@ STRICT CONSTRAINTS:
className: "w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-amber-800 text-zinc-200 flex items-center justify-between"
}, /*#__PURE__*/React.createElement("span", { className: "truncate" }, sf.display || sf.name || sf.id), /*#__PURE__*/React.createElement("span", { className: "text-[10px] text-amber-400 shrink-0 ml-1" }, "SF"))),
filteredInstruments.vst.length > 0 && /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 px-3 py-1 mt-1 uppercase font-bold" }, "VST Instruments"),
filteredInstruments.vst.map((v, i) => /*#__PURE__*/React.createElement("button", {
filteredInstruments.vst.map((v, i) => /*#__PURE__*/React.createElement("div", {
key: "vstd_" + i,
className: "flex items-stretch"
},
/*#__PURE__*/React.createElement("button", {
onClick: () => { setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null); setTrackInstrumentWithUndo(instrumentDropdownTrackId, v.id, v.name || v.id); },
className: "w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-violet-800 text-zinc-200 flex items-center justify-between"
}, /*#__PURE__*/React.createElement("span", { className: "truncate" }, v.name || v.id), /*#__PURE__*/React.createElement("span", { className: "text-[10px] text-cyan-400 shrink-0 ml-1" }, v.type || "VST"))),
className: "flex-1 min-w-0 text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-violet-800 text-zinc-200 flex items-center justify-between"
}, /*#__PURE__*/React.createElement("span", { className: "truncate" }, v.name || v.id), /*#__PURE__*/React.createElement("span", { className: "text-[10px] text-cyan-400 shrink-0 ml-1" }, v.type || "VST")),
window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.features && window.SonicRuntime.capabilities.features.carla_local ? /*#__PURE__*/React.createElement("button", {
onClick: (e) => { e.stopPropagation(); window.SonicAPI.openInCarla(v.id).then(function (r) { if (r && r.success) { showToast('Đã mở Carla: ' + (v.name || v.id), 'success'); } }).catch(function (err) { showToast('Lỗi mở Carla: ' + (err.message || err), 'error'); }); },
className: "shrink-0 px-2 text-xs bg-zinc-800 hover:bg-teal-700 text-teal-300 border-l border-zinc-700",
title: "Mở trong Carla (native GUI)"
}, "\uD83C\uDF9B") : null
)),
filteredInstruments.vst.length > 0 && /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 px-3 py-1 mt-1 uppercase font-bold" }, "VST Presets (.vstpreset)"),
(window.SonicRuntime && window.SonicRuntime.presets || []).map((p, i) => /*#__PURE__*/React.createElement("button", {
key: "psd_" + i,
onClick: () => setTrackPreset(instrumentDropdownTrackId, p.id),
className: "w-full text-left px-3 py-1 text-xs bg-zinc-800 hover:bg-emerald-800 text-zinc-300 flex items-center justify-between"
}, /*#__PURE__*/React.createElement("span", { className: "truncate" }, p.name || p.id), /*#__PURE__*/React.createElement("span", { className: "text-[10px] text-emerald-400 shrink-0 ml-1" }, "PRESET"))),
/*#__PURE__*/React.createElement("button", {
onClick: () => {
const inp = document.createElement('input');
inp.type = 'file';
inp.accept = '.vstpreset,.fxp,.fxb,.dspreset';
inp.onchange = () => {
const f = inp.files && inp.files[0];
if (!f) return;
window.SonicAPI.uploadPreset(f).then(function (r) {
if (r && r.success) {
if (window.SonicRuntime) window.SonicRuntime.refreshPresets();
showToast('Đã upload preset: ' + (r.original_name || r.name || ''), 'success');
} else { showToast('Upload preset thất bại', 'error'); }
}).catch(function (err) { showToast('Upload lỗi: ' + (err.message || err), 'error'); });
};
inp.click();
},
className: "w-full text-left px-3 py-1 text-xs bg-zinc-800 hover:bg-teal-800 text-teal-300 border-t border-zinc-700",
title: "Upload preset .vstpreset xuất từ Carla (native GUI)"
}, "\u2B06 Upload preset (t\u1EEB Carla...)"),
(!filteredInstruments.soundfonts.length && !filteredInstruments.vst.length) && /*#__PURE__*/React.createElement("p", { className: "text-xs text-zinc-500 py-4 text-center" }, "Kh\u00f4ng t\u00ecm th\u1ea5y nh\u1ea1c c\u1ee5 n\u00e0o")
)));
};
File diff suppressed because one or more lines are too long
+20
View File
@@ -17,6 +17,8 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
async function apiRequest(endpoint, options = {}) {
const url = `${window.API_BASE_URL}${endpoint}`;
const headers = { ...getAuthHeaders(), ...options.headers };
// FormData: browser tự đặt Content-Type kèm boundary — không được ép JSON
if (options.body instanceof FormData) delete headers['Content-Type'];
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
@@ -67,6 +69,24 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
getPluginDirs: () => apiRequest('/api/v1/plugins/dirs', { method: 'GET' }),
savePluginDirs: (dirs) => apiRequest('/api/v1/plugins/dirs', { method: 'POST', body: JSON.stringify(dirs) }),
scanPluginDirs: () => apiRequest('/api/v1/plugins/scan', { method: 'POST' }),
// Runtime capabilities — frontend gọi lúc boot để biết môi trường
// (desktop Windows / docker headless) và bật/tắt tính năng
getCapabilities: () => apiRequest('/api/v1/system/capabilities', { method: 'GET' }),
// Định vị Carla.exe (bản portable zip không cài đặt/PATH) — lưu config
setCarlaPath: (path) => apiRequest('/api/v1/system/carla-path', { method: 'POST', body: JSON.stringify({ carla_path: path }) }),
// Mở native GUI VSTi trong Carla (chỉ khi runtime=desktop + có Carla local)
openInCarla: (pluginName, pluginPath) => apiRequest('/api/v1/plugins/open-in-carla', { method: 'POST', body: JSON.stringify({ plugin_name: pluginName, plugin_path: pluginPath }) }),
// Quick-render preview VSTi (âm thật = âm export, cùng code path)
previewInstrument: (payload) => apiRequest('/api/v1/plugins/preview', { method: 'POST', body: JSON.stringify(payload) }),
// Thư viện preset VST3 (.vstpreset) — cầu nối Carla → pedalboard
listPresets: () => apiRequest('/api/v1/presets', { method: 'GET' }),
uploadPreset: (file, pluginHint) => {
const fd = new FormData();
fd.append('file', file);
if (pluginHint) fd.append('plugin_hint', pluginHint);
return apiRequest('/api/v1/presets/upload', { method: 'POST', body: fd });
},
deletePreset: (presetId) => apiRequest(`/api/v1/presets/${presetId}`, { method: 'DELETE' }),
// Native folder picker (Explorer qua Tauri bridge / PowerShell) —
// user yêu cầu dùng window explorer, không nhập tay
pickPluginDir: () => apiRequest('/api/v1/plugins/pick-dir', { method: 'POST' }),
+62
View File
@@ -0,0 +1,62 @@
// SonicForge Runtime service — phát hiện môi trường chạy (desktop Windows /
// docker headless) qua /api/v1/system/capabilities, bật/tắt tính năng theo đó.
// - data-runtime trên <html>: "desktop" | "headless"
// - data-carla="1": có Carla local (hiện nút "Mở trong Carla")
// - Phần tử có thuộc tính data-carla-only sẽ bị ẩn khi không có Carla local.
// - Thư viện preset (.vstpreset) cache trong SonicRuntime.presets — dùng cho
// dropdown gán preset vào track (Carla → pedalboard bridge).
window.SonicRuntime = window.SonicRuntime || { loaded: false, capabilities: null, presets: null };
(function () {
function getHeaders() {
const token = localStorage.getItem('sonic_token') || '';
return token ? { 'Authorization': 'Bearer ' + token } : {};
}
function load() {
return fetch(window.API_BASE_URL + '/api/v1/system/capabilities')
.then(function (r) { return r.json(); })
.then(function (data) {
var c = data && data.success ? data : { features: {} };
window.SonicRuntime.capabilities = c;
window.SonicRuntime.loaded = true;
var html = document.documentElement;
html.dataset.runtime = c.runtime || 'unknown';
html.dataset.platform = c.platform || '';
html.dataset.carla = (c.features && c.features.carla_local) ? '1' : '0';
if (c.features && c.features.carla_local === false) {
document.querySelectorAll('[data-carla-only]').forEach(function (el) {
el.style.display = 'none';
});
}
// Cache sẵn danh sách preset (static, ít thay đổi)
listPresets().catch(function () {});
return c;
})
.catch(function () { return null; });
}
function listPresets() {
if (window.SonicRuntime.presets) return Promise.resolve(window.SonicRuntime.presets);
return fetch(window.API_BASE_URL + '/api/v1/presets', { headers: getHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
window.SonicRuntime.presets = (d && d.presets) || [];
return window.SonicRuntime.presets;
})
.catch(function () { return []; });
}
window.SonicRuntime.load = load;
window.SonicRuntime.listPresets = listPresets;
window.SonicRuntime.refreshPresets = function () {
window.SonicRuntime.presets = null;
return window.SonicRuntime.listPresets();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', load);
} else {
load();
}
})();
+1
View File
@@ -31,6 +31,7 @@
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
<script src="/static/js/services/runtime.js?v=20260809"></script>
<script src="/static/js/services/api.js?v=202608091400"></script>
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
<script src="/static/js/services/storage.js?v=202608038200"></script>
+5
View File
@@ -11,6 +11,11 @@ services:
ports:
- "${WEB_PORT:-8000}:8000"
env_file: .env
environment:
# Ép nhận diện headless (server docker + browser UI) — soundfont + VSTi
# mở được lấy từ storage mount bên dưới; preset VST3 (.vstpreset) upload
# qua web UI vào storage/presets (nằm trong volume sf_db).
- SF_DOCKER=1
volumes:
- sf_uploads:/app/app/storage/uploads
- sf_processed:/app/app/storage/processed
+222
View File
@@ -0,0 +1,222 @@
# CARLA BRIDGE — NATIVE GUI VSTi (Windows) + DUAL-MODE RUNTIME (Desktop / Docker Headless)
> Tài liệu kỹ thuật & vận hành: tích hợp Carla làm host native GUI cho VSTi trên
> Windows, tự phát hiện môi trường (desktop / headless), thư viện preset
> (.vstpreset) làm cầu nối Carla → pedalboard, và Plugin Manager quản lý
> soundfont + instrument.
>
> Phạm vi code: `app/core/runtime.py`, `app/api/v1/system.py`,
> `app/api/v1/presets.py`, `app/api/v1/plugins.py`, `app/core/vst_engine.py`,
> `app/core/render_engine.py`, `app/static/js/services/runtime.js`, `app.jsx`.
---
## 1. Bối cảnh & quyết định kiến trúc
| Vấn đề | Quyết định |
|---|---|
| `pedalboard` **cố tình headless** — không mở được native GUI VSTi | Không thay thế pedalboard; dùng **Carla làm host GUI ngoài** (preview + chỉnh preset), pedalboard giữ nguyên làm **render engine** (offline, cùng code path cho preview & export) |
| Browser không chạy được binary `.vst3` (Windows/Linux) | Preview realtime thật chỉ có trong **cửa sổ Carla**; trong web UI dùng **quick-render preview** (pedalboard render clip ngắn → wav — **âm thật = âm export**) |
| App chạy 2 môi trường: **Windows desktop** (server+client 1 máy) và **Docker headless** (server + browser UI) | **Runtime profile tự phát hiện** → bật/tắt tính năng theo môi trường (nút Carla Bridge chỉ hiện trên desktop có Carla) |
| Carla Windows là **bộ zip portable** — không installer, không PATH | **User tự định vị** thư mục chứa `carla.exe` (Plugin Manager → Định vị Carla...) + tìm kiếm dự phòng (registry, Program Files, quét nông Downloads/Desktop) |
```
┌────────────────────────── Windows Desktop (1 máy) ──────────────────────────┐
│ Browser/Tauri UI ──► FastAPI (localhost:8000) ──► pedalboard (render VST3) │
│ │ │ ▲ │
│ ▼ ▼ │ load_preset │
│ Nút "Carla Bridge" ──► spawn carla.exe (native GUI) │ │
│ │ chọn VSTi, chỉnh âm, Save .vstpreset │
│ └──────────────► Upload preset ──► storage/presets ─┘ │
└─────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────── Docker Headless (server) ─────────────────────────┐
│ Browser UI ──► FastAPI (container :8000) ──► pedalboard (VSTi Linux) │
│ Soundfont + VSTi mở được: mount volumes (docker-compose.prod.yml) │
│ Preset .vstpreset: user chỉnh ở máy desktop → upload qua web UI │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 2. Runtime tự phát hiện môi trường (`app/core/runtime.py`)
### 2.1 Nguyên tắc
- **Heuristic + override**: `SF_RUNTIME=auto|desktop|headless` **thắng tuyệt đối**
(WSL / docker-in-docker / remote desktop có thể làm heuristic sai).
- `SF_DOCKER=1` hoặc tồn tại `/.dockerenv` → nhận diện container.
- Linux: không có `DISPLAY` hoặc trong docker → `headless`; ngược lại `desktop`.
- Windows/macOS → luôn `desktop`.
### 2.2 Capabilities API (frontend gọi 1 lần lúc boot)
```
GET /api/v1/system/capabilities (public — cần trước khi đăng nhập)
```
```json
{
"success": true,
"runtime": "desktop", // "desktop" | "headless"
"platform": "windows", // "windows" | "linux" | "darwin"
"docker": false,
"features": {
"carla_local": true, // có Carla trên máy → hiện nút "Carla Bridge"
"carla_path": "D:/Tools/Carla/carla.exe",
"preset_upload": true, // luôn true (upload .vstpreset qua web UI)
"vst_render": true, // pedalboard khả dụng
"tauri_bridge": false,
"preview_mode": "quick_render" // "quick_render" | "wasm"
},
"default_dirs": { "vst": [...], "soundfont": [...], "preset": "..." }
}
```
### 2.3 Bảng hành vi theo môi trường
| Tính năng | Windows desktop | Docker headless |
|---|---|---|
| Render VSTi | pedalboard `VST3Plugin` + `load_preset` | giống hệt (VSTi **bản Linux**) |
| Scan VST/SF | thư mục chuẩn Windows + `plugin_dirs.json` | mount volumes + `plugin_dirs.json` |
| Preview VSTi | quick-render (âm thật); realtime trong Carla | quick-render |
| Nút "Carla Bridge" (nút Synth) | **Hiện** → spawn `carla.exe` | **Ẩn** (không có GUI local) |
| Preset | picker local / upload → thư viện | upload → thư viện |
| SoundFont preview | FluidSynth WASM (browser) | giống hệt |
---
## 3. Cài đặt Carla trên Windows (bản portable zip)
Carla phát hành dạng **zip** (`Carla-2.5.x-win64.zip`) — **không có installer,
không ghi PATH**. Không bundle Carla vào installer của app (license GPL-2.0+,
xem §8).
1. Tải: <https://github.com/falkTX/Carla/releases> (bản `win64`).
2. Giải nén ra bất kỳ đâu (VD `D:\Tools\Carla\`, chứa `carla.exe`).
3. Mở app → **Plugin Manager** → section **"Carla Bridge (VSTi native GUI)"** →
nút **"Định vị Carla..."** → chọn thư mục chứa `carla.exe` (hoặc chính file
`carla.exe`).
4. App lưu vào `storage/carla_path.json` → cache detect bị xóa → nút
**"Carla Bridge"** hiện trong dropdown nút Synth.
### 3.1 Thứ tự phát hiện `carla_local`
| Ưu tiên | Nguồn |
|---|---|
| 1 | **Config user** (`storage/carla_path.json`) — kênh chính cho bản portable |
| 2 | PATH (`shutil.which`) |
| 3 | Registry (`HKLM/HKCU\SOFTWARE\Carla\InstallPath`) — nếu cài qua installer |
| 4 | Thư mục chuẩn (`Program Files\Carla`, `%LOCALAPPDATA%\Programs\Carla`) |
| 5 | **Quét nông** Downloads/Desktop/Documents (độ sâu ≤ 3, bỏ qua `node_modules`, `AppData`, `Windows`...) — không bao giờ quét toàn ổ đĩa |
```
POST /api/v1/system/carla-path (auth) — lưu vị trí Carla
body: { "carla_path": "D:/Tools/Carla" } (thư mục HOẶC file exe)
→ trả { success, carla_path, ...capabilities }
```
---
## 4. Luồng sử dụng end-to-end (Windows)
1. **Nút Synth** (track strip / btnSynth) → chọn **"🎛 Carla Bridge (mở Carla.exe)"**
→ app spawn `carla.exe` (ưu tiên `carla-single <plugin>` nếu chọn kèm plugin).
2. Trong Carla: **Add Plugin** → chọn VSTi (Kontakt, Nexus, Vital...) → native GUI
hiện ra → chỉnh âm, chọn bank/preset của plugin.
3. **Save preset** bằng nút của CHÍNH plugin (không dùng project save của Carla)
→ file `.vstpreset`.
4. Trong app: dropdown Synth → **"⬆ Upload preset (từ Carla...)"** → chọn file
`.vstpreset` → nằm trong **thư viện preset** → chọn preset trong danh sách
**"VST Presets"** → gán vào track (`synth_engine.preset_id`).
5. **Render**: `render_engine.py` nạp plugin qua `load_vst()` rồi
`apply_preset_to_plugin()` → **âm render = âm đã chỉnh trong Carla**
(điều kiện: cùng sample rate — xem §7).
Lưu ý: danh sách VSTi trong dropdown vẫn cần thiết — dùng để **map tên →
đường dẫn khi pedalboard nạp plugin lúc render** (không phải để mở GUI).
---
## 5. Thư viện preset — cầu nối Carla ↔ pedalboard
- Thư mục: `{STORAGE_DIR}/presets` (Windows: `%APPDATA%\SonicForgeDAW\storage\presets`;
Docker: nằm trong volume `sf_db``/app/app/storage/presets`).
- Định dạng hỗ trợ: `.vstpreset` (VST3 — chuẩn), `.fxp`/`.fxb` (VST2, chỉ
preview trong Carla), `.dspreset` (DecentSampler/Pianobook).
- `synth_engine` của track có thể chứa 1 trong 3 dạng (ưu tiên giảm dần):
```json
{ "type": "vst3", "plugin_id": "Kontakt 7",
"preset_data": "<base64 bytes .vstpreset nhúng — project tự chứa state, portable>",
"preset_id": "a1b2c3....vstpreset", // thư viện storage/presets
"preset_path": "D:/presets/Piano.vstpreset" }
```
### API
```
GET /api/v1/presets (public) — danh sách
POST /api/v1/presets/upload (auth) — multipart file + plugin_hint
GET /api/v1/presets/{id}/download
DELETE /api/v1/presets/{id} (auth)
```
### Quick-render preview (âm thật = âm export)
```
POST /api/v1/plugins/preview
body: { instrument_id, notes:[{pitch,start_beat,duration_beats,velocity}],
bpm, sample_rate, preset_id?, preset_path?, preset_data? }
→ { success, url: "/static/audio/processed/preview_xxx.wav", duration_sec }
```
Cùng code path với export (pedalboard + `load_preset`) → **preview nghe đúng
plugin/preset** (khác "Preview Synth WASM" cũ — âm giả).
---
## 6. Plugin Manager — SoundFont: Add Directory → Scan → Instrument → Synth
1. **Add Directory** (folder picker native / in-app browser) → thư mục vào
danh sách (lưu `plugin_dirs.json`).
2. **Scan** → quét soundfont trong các thư mục (catalog qua
`SoundFontAutoScanner`) + liệt kê VST.
3. Bấm vào **một soundfont** (▸) → expand danh sách **instrument bên trong**
(Bank/Program/Tên) qua `GET /api/v1/plugins/soundfont-instruments/{sf_id}`.
4. Nút **"Chèn vào Synth"** → gán instrument (bank/program) vào **track đang
chọn** (`setTrackInstrumentWithProgram`) và đóng Plugin Manager.
> SF3: instrument đọc sau khi chuyển đổi SF3→SF2 (endpoint download tự chuyển
> đổi khi cần). Nếu thiếu libfluidsynth, danh sách trả `[]` (graceful).
---
## 7. Lưu ý kỹ thuật & hạn chế (đã xác minh)
1. **Sample rate**: Carla chạy theo audio device (thường 48 kHz), pedalboard
render mặc định 44.1 kHz → plugin phụ thuộc SR (delay/chorus/oversampling)
nghe khác. **Render đúng SR của thiết bị Carla** để preview = export.
2. **VST2**: pedalboard 0.10+ **đã gỡ hỗ trợ VST2** (chỉ còn VST3) → chỉ preset
VST3 (`.vstpreset`) round-trip được. Plugin VST2 cũ: preview được trong
Carla nhưng **không render** được qua pedalboard.
3. **VSTi trên Docker**: chỉ chạy được plugin có bản **Linux** (`.vst3`/`.so`);
plugin Windows-only (`.dll`) không chạy trên server Linux (không khuyến nghị
Wine bridge trong container).
4. **License Carla GPL-2.0+**: app **không bundle/nhúng** Carla — chỉ spawn
tiến trình ngoài + trao đổi file preset (không link code) → không dính
copyleft. User tự tải zip.
5. **Preview WASM ≠ âm thật**: Preview Synth trong browser không phải plugin
thật — dùng quick-render (`/plugins/preview`) nếu cần nghe đúng âm.
---
## 8. Checklist QA
- [ ] Windows: cài Carla zip → Định vị Carla → nút "Carla Bridge" hiện ở nút Synth
- [ ] Bấm Carla Bridge → `carla.exe` chạy, mở được VSTi + native GUI
- [ ] Save `.vstpreset` từ GUI plugin → Upload trong app → gán vào track → render ra âm đúng preset
- [ ] Render cùng SR với Carla → preview (Carla) nghe = âm export
- [ ] Plugin Manager: Add Directory → Scan → expand soundfont → "Chèn vào Synth" gán đúng bank/program vào track đang chọn
- [ ] Docker (`SF_DOCKER=1`): capabilities `runtime=headless`, không hiện Carla Bridge, preset upload hoạt động, render VSTi Linux + soundfont từ mount OK
- [ ] `SF_RUNTIME=desktop` trên Linux có DISPLAY → chạy như desktop
- [ ] pytest: `86 passed, 7 skipped`
+5
View File
@@ -3031,3 +3031,8 @@
- **FIX (app.jsx effect follow):** `const playing = isPlaying || !!st.isPlaying` — follow khi piano roll play LẪN main play; deps thêm `st.isPlaying`.
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070950), `wiki.md`. Rebuild precompiled (build PASS).
- **Ghi chú/Test:** hard refresh → PIANO ROLL → play (tab) → playhead ở giữa + notes trôi trái; stop → về đầu.
### [2026-08-09] Task: Carla Bridge (native GUI VSTi) + dual-mode runtime + preset library
- **Tóm tắt thay đổi:** (1) Runtime tự phát hiện môi trường `app/core/runtime.py` (desktop Windows / docker headless; override `SF_RUNTIME`/`SF_DOCKER`) + `GET /api/v1/system/capabilities` (public) — frontend bật/tắt tính năng theo môi trường. (2) Carla Bridge: mục "🎛 Carla Bridge (mở Carla.exe)" trong dropdown nút Synth (chỉ hiện khi desktop + có Carla local) → spawn `carla.exe` qua `POST /api/v1/plugins/open-in-carla`; vì bản Windows là zip portable (không installer, không PATH) → Plugin Manager thêm section "Carla Bridge" + nút "Định vị Carla..." (`POST /api/v1/system/carla-path`, lưu `storage/carla_path.json` ưu tiên cao nhất; kèm registry + quét nông Downloads/Desktop/Documents giới hạn độ sâu). (3) Thư viện preset `app/api/v1/presets.py` (storage/presets: list/upload/download/delete, chống path traversal) + `apply_preset_to_plugin()` trong `vst_engine.py` (preset_data base64 → preset_id → preset_path) → render_engine nạp preset trước khi render VST3 → âm render = âm đã chỉnh trong Carla. (4) `POST /api/v1/plugins/preview` — quick-render preview (cùng code path pedalboard với export → âm thật). (5) Plugin Manager: bấm soundfont expand → liệt kê instrument (bank/program/name) + nút "Chèn vào Synth" gán vào track đang chọn. (6) `config.py` default VST_DIR/SOUNDFONT_DIR theo platform + `PRESET_DIR`; docker-compose `SF_DOCKER=1`; service `runtime.js` (capabilities lúc boot, cache preset) + api.js methods mới (getCapabilities/setCarlaPath/openInCarla/previewInstrument/listPresets/uploadPreset/deletePreset).
- **Các file ảnh hưởng:** `app/core/runtime.py` (mới), `app/api/v1/system.py` (mới), `app/api/v1/presets.py` (mới), `app/api/v1/plugins.py`, `app/core/vst_engine.py`, `app/core/render_engine.py`, `app/config.py`, `app/main.py`, `app/static/js/services/runtime.js` (mới), `app/static/js/services/api.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html`, `.env.example`, `docker-compose.prod.yml`, `md/52_CARLA_BRIDGE.md` (mới), `wiki.md`
- **Ghi chú/Test (nếu có):** `pytest`: 86 passed, 7 skipped. Test API: capabilities 200; preset CRUD + chặn path traversal; carla-path (thư mục/exe → resolve exe, invalid → 400); open-in-carla → 409 kèm hướng dẫn khi chưa có Carla; preview → 501 khi thiếu pedalboard. Rebuild bundle BUILD OK. Lưu ý: pedalboard 0.10+ đã bỏ VST2 (chỉ preset VST3 `.vstpreset` round-trip); render cùng sample rate với Carla để preview = export; Carla GPL-2.0+ → không bundle/nhúng, chỉ spawn tiến trình ngoài.