diff --git a/.env.example b/.env.example
index 1fa0710..12e03ea 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py
index 0b4903f..7afc66d 100644
--- a/app/api/v1/plugins.py
+++ b/app/api/v1/plugins.py
@@ -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,
diff --git a/app/api/v1/presets.py b/app/api/v1/presets.py
new file mode 100644
index 0000000..486922c
--- /dev/null
+++ b/app/api/v1/presets.py
@@ -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}
diff --git a/app/api/v1/system.py b/app/api/v1/system.py
new file mode 100644
index 0000000..d3fcc32
--- /dev/null
+++ b/app/api/v1/system.py
@@ -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()}
+
diff --git a/app/config.py b/app/config.py
index 431027a..7f0f30a 100644
--- a/app/config.py
+++ b/app/config.py
@@ -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()
diff --git a/app/core/render_engine.py b/app/core/render_engine.py
index 69d8df2..09d7fc2 100644
--- a/app/core/render_engine.py
+++ b/app/core/render_engine.py
@@ -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
diff --git a/app/core/runtime.py b/app/core/runtime.py
new file mode 100644
index 0000000..1249676
--- /dev/null
+++ b/app/core/runtime.py
@@ -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))
diff --git a/app/core/vst_engine.py b/app/core/vst_engine.py
index 3d86d57..24392f8 100644
--- a/app/core/vst_engine.py
+++ b/app/core/vst_engine.py
@@ -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
+
diff --git a/app/main.py b/app/main.py
index 45e2e91..82d165d 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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")
diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx
index 1ca94cd..45973cb 100644
--- a/app/static/js/app.jsx
+++ b/app/static/js/app.jsx
@@ -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 mỗi 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} → hiển 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 — bản 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 thấy → user tự chọn thư mục
+ // chứa 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ư mục → refresh list + catalog.
const saveAndScanDirs = async () => {
setPmScanning(true);
@@ -5545,28 +5596,78 @@ 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 gap-3' },
- React.createElement('div', { className: 'w-8 h-8 rounded bg-amber-900/30 flex items-center justify-center' },
- React.createElement('i', { 'data-lucide': 'music', className: 'w-4 h-4 text-amber-400' })
+ 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' },
+ React.createElement('i', { 'data-lucide': 'music', className: 'w-4 h-4 text-amber-400' })
+ ),
+ 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) + (insts.length ? ' — ' + insts.length + ' instruments' : ''))
+ )
),
- 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: 'flex items-center gap-2' },
+ React.createElement('span', { className: 'text-[10px] text-zinc-500' }, expanded ? '▾' : '▸'),
+ React.createElement('button', {
+ 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')
)
),
- React.createElement('div', { className: 'flex items-center gap-2' },
- React.createElement('button', {
- onClick: () => 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 }) => {
selectSynthInst(null)}>
None (mặc định)
+ {window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.features && window.SonicRuntime.capabilities.features.carla_local && (
+ { 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')); }}>
+ 🎛 Carla Bridge (mở Carla.exe)
+
+ )}
{!synthLoading && filteredSynthList && filteredSynthList.map(group => (
{group.sf.display || group.sf.name || group.sf.id}
@@ -15520,6 +15626,27 @@ const App = () => {
setAiPrompt(newText);
};
+ // Gán preset VST3 (.vstpreset từ thư viện storage/presets — cầu nối
+ // Carla → pedalboard) vào track: preset_id nằm trong synth_engine → render
+ // engine tải qua load_preset khi render → âm render = âm đã chỉnh 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 chọn — 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,
- 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 items-stretch"
+ },
+ /*#__PURE__*/React.createElement("button", {
+ onClick: () => { setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null); setTrackInstrumentWithUndo(instrumentDropdownTrackId, v.id, v.name || v.id); },
+ 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")
)));
};
diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js
index 9ca13d2..072b688 100644
--- a/app/static/js/app.precompiled.js
+++ b/app/static/js/app.precompiled.js
@@ -362,8 +362,10 @@ const GraphEditorCanvas=({buffer,zoom,timelineWidth,volumeNodes,panningNodes,fad
// username/mật khẩu; sau khi đã đổi → ẩn vĩnh viễn.
useEffect(()=>{if(!isOpen)return;if(window.SonicAPI&&window.SonicAPI.apiRequest){window.SonicAPI.apiRequest('/api/v1/auth/first-time',{method:'GET'}).then(function(d){setFirstTime(!!(d&&d.first_time));})// Fail-closed: endpoint lỗi/404 (backend chưa restart) → ẨN gợi ý
// (không hiện — user đã yêu cầu bỏ gợi ý sau lần đầu).
-.catch(function(){setFirstTime(false);});}else{setFirstTime(false);}},[isOpen]);const handleSubmit=async e=>{e.preventDefault();setError('');setLoading(true);try{if(activeTab==='login'){const targetUsername=username.trim()||'admin';const targetPwd=password.trim()||'admin123';const res=await window.SonicAPI.login(targetUsername,targetPwd);localStorage.setItem('sonic_token',res.access_token);localStorage.setItem('sonic_user',JSON.stringify(res.user));if(res.user&&res.user.must_change_password){setOldPassword(targetPwd);}onSuccess(res.user,res.access_token);}else if(activeTab==='register'){const res=await window.SonicAPI.register(username.trim(),email.trim(),password.trim());localStorage.setItem('sonic_token',res.access_token);localStorage.setItem('sonic_user',JSON.stringify(res.user));onSuccess(res.user,res.access_token);}else if(activeTab==='force_change'){const res=await window.SonicAPI.changePassword(oldPassword.trim(),newPassword.trim());localStorage.setItem('sonic_token',res.access_token);const user=JSON.parse(localStorage.getItem('sonic_user')||'{}');user.must_change_password=false;localStorage.setItem('sonic_user',JSON.stringify(user));onSuccess(user,res.access_token);}}catch(err){setError(err.message||(activeTab==='login'?'Tài khoản hoặc mật khẩu không chính xác. (Nếu bạn đã đổi mật khẩu trước đó, vui lòng nhập mật khẩu mới mà bạn đã tạo)':'Thao tác không thành công'));}finally{setLoading(false);}};const isForceMode=activeTab==='force_change';const canClose=!forceMandatory&&!isForceMode;return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center pb-4 border-b border-[#383838]"},/*#__PURE__*/React.createElement("h3",{className:"text-lg font-bold text-teal-400"},isForceMode?'⚠️ Bắt Buộc Đổi Mật Khẩu Khởi Tạo':activeTab==='login'?'🔐 Đăng Nhập Hệ Thống':'📝 Đăng Ký Tài Khoản'),canClose&&/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-slate-200"},"✕")),error&&/*#__PURE__*/React.createElement("div",{className:"mt-4 p-3 bg-red-900/40 border border-red-700 rounded-lg text-red-200 text-sm"},error),/*#__PURE__*/React.createElement("form",{onSubmit:handleSubmit,className:"mt-4 space-y-4"},isForceMode?/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("p",{className:"text-xs text-amber-400 bg-amber-950/60 p-2.5 border border-amber-800/80 rounded leading-relaxed"},"🔒 Tài khoản của bạn đang dùng mật khẩu khởi tạo mặc định. Để bảo mật hệ thống, bạn phải đổi mật khẩu mới trước khi tiếp tục."),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Mật khẩu hiện tại (Mặc định: admin123)"),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:"current-password",required:true,value:oldPassword,onChange:e=>setOldPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Mật khẩu mới"),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:"new-password",required:true,value:newPassword,onChange:e=>setNewPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"}))):/*#__PURE__*/React.createElement(React.Fragment,null,activeTab==='login'?/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Tên đăng nhập ",firstTime&&/*#__PURE__*/React.createElement("span",{className:"text-teal-400 font-normal"},"(Tùy chọn - Admin có thể bỏ trống)")),/*#__PURE__*/React.createElement("input",{type:"text",placeholder:"Mặc định: admin",value:username,onChange:e=>setUsername(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})):/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Tên đăng nhập"),/*#__PURE__*/React.createElement("input",{type:"text",required:true,value:username,onChange:e=>setUsername(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})),activeTab==='register'&&/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Email"),/*#__PURE__*/React.createElement("input",{type:"email",required:true,value:email,onChange:e=>setEmail(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Mật khẩu ",activeTab==='login'&&firstTime&&/*#__PURE__*/React.createElement("span",{className:"text-amber-400 font-normal"},"(Lần đầu: admin123)")),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:activeTab==='login'?'current-password':'new-password',required:true,value:password,onChange:e=>setPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"}))),/*#__PURE__*/React.createElement("button",{type:"submit",disabled:loading,className:"w-full py-2 bg-teal-600 hover:bg-teal-500 text-white font-semibold rounded-lg shadow transition duration-150"},loading?'Đang xác thực...':isForceMode?'Đổi Mật Khẩu Ngay':activeTab==='login'?'Đăng Nhập System':'Tạo Tài Khoản Mới'),activeTab==='login'&&!isForceMode&&firstTime&&/*#__PURE__*/React.createElement("button",{type:"button",onClick:()=>{setUsername('admin');setPassword('admin123');setError('');},className:"w-full mt-2 py-1.5 bg-amber-950/60 hover:bg-amber-900/80 text-amber-300 border border-amber-800/60 text-xs font-semibold rounded-lg transition flex items-center justify-center gap-1.5"},"🔑 Điền Nhanh Tài Khoản Admin (admin / admin123)")),!isForceMode&&/*#__PURE__*/React.createElement("div",{className:"mt-4 pt-4 border-t border-[#383838] text-center text-xs text-slate-400"},activeTab==='login'?/*#__PURE__*/React.createElement("span",null,"Chưa có tài khoản? ",/*#__PURE__*/React.createElement("button",{onClick:()=>setActiveTab('register'),className:"text-teal-400 hover:underline"},"Đăng ký ngay")):/*#__PURE__*/React.createElement("span",null,"Đã có tài khoản? ",/*#__PURE__*/React.createElement("button",{onClick:()=>setActiveTab('login'),className:"text-teal-400 hover:underline"},"Đăng nhập")))));};const AIConfigModal=({isOpen,onClose,onConfigSaved})=>{if(!isOpen)return null;const defaultProvidersList=[{id:'openai_default',name:'OpenAI Official',provider_type:'openai',api_base_url:'https://api.openai.com/v1',api_key:'',model_name:'gpt-4o',temperature:0.7,is_active:true},{id:'openai_compat_default',name:'OpenAI Compatible (Ollama/LocalAI/DeepSeek)',provider_type:'openai_compatible',api_base_url:'http://localhost:11434/v1',api_key:'ollama',model_name:'deepseek-r1',temperature:0.7,is_active:false},{id:'anthropic_default',name:'Anthropic Claude',provider_type:'anthropic',api_base_url:'https://api.anthropic.com/v1',api_key:'',model_name:'claude-3-5-sonnet',temperature:0.7,is_active:false},{id:'gemini_default',name:'Google Gemini',provider_type:'gemini',api_base_url:'https://generativelanguage.googleapis.com',api_key:'',model_name:'gemini-1.5-pro',temperature:0.7,is_active:false}];const[providers,setProviders]=useState(defaultProvidersList);const[selectedId,setSelectedId]=useState('openai_default');const[msg,setMsg]=useState('');const[error,setError]=useState('');const[loading,setLoading]=useState(false);useEffect(()=>{if(isOpen)loadConfigs();},[isOpen]);const loadConfigs=async()=>{setLoading(true);setError('');try{const data=await window.SonicAPI.getAIConfigs();if(data&&data.providers){setProviders(data.providers);if(data.providers.length>0)setSelectedId(data.providers[0].id);}}catch(err){setError(err.message||'Lỗi nạp cấu hình AI');}finally{setLoading(false);}};const handleSave=async e=>{e.preventDefault();setMsg('');setError('');setLoading(true);try{const res=await window.SonicAPI.saveAIConfigs(providers);setMsg(res.message||'Đã lưu cấu hình AI Providers thành công!');if(onConfigSaved)onConfigSaved(providers);}catch(err){setError(err.message||'Lỗi khi lưu cấu hình AI');}finally{setLoading(false);}};const updateProviderField=(id,field,value)=>{setProviders(prev=>prev.map(p=>p.id===id?{...p,[field]:value}:p));};const activeProvider=providers.find(p=>p.id===selectedId)||providers[0];return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm select-none"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center pb-4 border-b border-[#383838]"},/*#__PURE__*/React.createElement("h3",{className:"text-lg font-bold text-cyan-400 flex items-center gap-2"},"🤖 Quản Lý & Cấu Hình AI Providers"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-slate-200"},"✕")),msg&&/*#__PURE__*/React.createElement("div",{className:"mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs"},msg),error&&/*#__PURE__*/React.createElement("div",{className:"mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs"},error),/*#__PURE__*/React.createElement("div",{className:"mt-4 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"space-y-1.5 border-r border-[#383838] pr-3"},/*#__PURE__*/React.createElement("span",{className:"text-xs uppercase font-bold text-slate-400 block mb-2"},"Providers"),providers.map(p=>/*#__PURE__*/React.createElement("button",{key:p.id,tabIndex:0,onClick:()=>setSelectedId(p.id),onKeyDown:e=>{if(e.key===' '){e.preventDefault();updateProviderField(p.id,'is_active',!p.is_active);}},className:`w-full text-left px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-between transition ${selectedId===p.id?'bg-cyan-600 text-white shadow':'bg-[#1e1e1e] text-slate-300 hover:bg-[#2e2e2e]'}`},/*#__PURE__*/React.createElement("span",{className:"truncate"},p.name),p.is_active&&/*#__PURE__*/React.createElement("span",{className:"w-2 h-2 rounded-full bg-emerald-400"}))),/*#__PURE__*/React.createElement("div",{className:"flex gap-1 mt-2"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();const rearrangeNewId='provider_'+Date.now();setProviders(prev=>[...prev,{id:rearrangeNewId,name:'New Provider',provider_type:'openai_compatible',api_base_url:'https://api.openai.com/v1',api_key:'',model_name:'gpt-4o-mini',temperature:0.7,is_active:false}]);setSelectedId(rearrangeNewId);},className:"flex-1 px-2 py-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-bold rounded"},"+ Thêm"),providers.length>1&&/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();if(confirm(`Xóa provider "${providers.find(p=>p.id===selectedId)?.name}"?`)){setProviders(prev=>{const filtered=prev.filter(p=>p.id!==selectedId);if(filtered.length>0)setSelectedId(filtered[0].id);return filtered;});}},className:"px-2 py-1 bg-red-800 hover:bg-red-700 text-white text-xs font-bold rounded"},"Xóa"),providers.length>1&&/*#__PURE__*/React.createElement("button",{onClick:function(e){e.stopPropagation();var provs=providers.slice();var pos=-1;for(var pi=0;pi
0){var tmp=provs[pos];provs[pos]=provs[pos-1];provs[pos-1]=tmp;setProviders(provs);}},className:"px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded",title:"Di chuyển lên"},"▲"),providers.length>1&&/*#__PURE__*/React.createElement("button",{onClick:function(e){e.stopPropagation();var provs=providers.slice();var pos=-1;for(var pi=0;piupdateProviderField(activeProvider.id,'name',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"API Base URL (Endpoint)"),/*#__PURE__*/React.createElement("input",{type:"text",value:activeProvider.api_base_url||'',onChange:e=>updateProviderField(activeProvider.id,'api_base_url',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-cyan-300 focus:outline-none focus:border-cyan-500",placeholder:"https://api.openai.com/v1"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"API Key Cá Nhân"),/*#__PURE__*/React.createElement("input",{type:"password",value:activeProvider.api_key||'',onChange:e=>updateProviderField(activeProvider.id,'api_key',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-slate-200 focus:outline-none focus:border-cyan-500",placeholder:"sk-..."})),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-3"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"Model Name"),/*#__PURE__*/React.createElement("input",{type:"text",value:activeProvider.model_name||'',onChange:e=>updateProviderField(activeProvider.id,'model_name',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-amber-300 focus:outline-none focus:border-cyan-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"Temperature"),/*#__PURE__*/React.createElement("input",{type:"number",step:"0.1",min:"0",max:"2",value:activeProvider.temperature??0.7,onChange:e=>updateProviderField(activeProvider.id,'temperature',parseFloat(e.target.value)),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"}))),/*#__PURE__*/React.createElement("div",{className:"pt-2 flex items-center justify-between"},/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-2 cursor-pointer text-xs text-slate-300"},/*#__PURE__*/React.createElement("input",{type:"checkbox",checked:activeProvider.is_active,onChange:e=>updateProviderField(activeProvider.id,'is_active',e.target.checked),className:"rounded accent-cyan-500"}),"Kích hoạt Provider này"),/*#__PURE__*/React.createElement("button",{type:"submit",disabled:loading,className:"px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold text-xs rounded-lg shadow transition"},loading?'Đang lưu...':'Lưu Cấu Hình AI'))))));};const PluginManagerModal=({isOpen,onClose,pluginsData})=>{if(!isOpen)return null;const[localData,setLocalData]=React.useState(pluginsData);const[sfUploadStatus,setSfUploadStatus]=React.useState('');const[sfToDelete,setSfToDelete]=React.useState(null);const[pmDirs,setPmDirs]=React.useState([]);const[pmScanning,setPmScanning]=React.useState(false);const[pmScanResult,setPmScanResult]=React.useState('');const[pmScanData,setPmScanData]=React.useState(null);// { vst_found, soundfonts }
-React.useEffect(()=>{if(isOpen){window.SonicAPI.listPlugins().then(data=>setLocalData(data)).catch(()=>setLocalData({vst_instruments:[],soundfonts:[]}));window.SonicAPI.getPluginDirs().then(d=>setPmDirs(d.plugin_dirs||[])).catch(()=>{});setTimeout(()=>{try{window.lucide.createIcons();}catch(e){}},50);}},[isOpen]);// Folder picker:
+.catch(function(){setFirstTime(false);});}else{setFirstTime(false);}},[isOpen]);const handleSubmit=async e=>{e.preventDefault();setError('');setLoading(true);try{if(activeTab==='login'){const targetUsername=username.trim()||'admin';const targetPwd=password.trim()||'admin123';const res=await window.SonicAPI.login(targetUsername,targetPwd);localStorage.setItem('sonic_token',res.access_token);localStorage.setItem('sonic_user',JSON.stringify(res.user));if(res.user&&res.user.must_change_password){setOldPassword(targetPwd);}onSuccess(res.user,res.access_token);}else if(activeTab==='register'){const res=await window.SonicAPI.register(username.trim(),email.trim(),password.trim());localStorage.setItem('sonic_token',res.access_token);localStorage.setItem('sonic_user',JSON.stringify(res.user));onSuccess(res.user,res.access_token);}else if(activeTab==='force_change'){const res=await window.SonicAPI.changePassword(oldPassword.trim(),newPassword.trim());localStorage.setItem('sonic_token',res.access_token);const user=JSON.parse(localStorage.getItem('sonic_user')||'{}');user.must_change_password=false;localStorage.setItem('sonic_user',JSON.stringify(user));onSuccess(user,res.access_token);}}catch(err){setError(err.message||(activeTab==='login'?'Tài khoản hoặc mật khẩu không chính xác. (Nếu bạn đã đổi mật khẩu trước đó, vui lòng nhập mật khẩu mới mà bạn đã tạo)':'Thao tác không thành công'));}finally{setLoading(false);}};const isForceMode=activeTab==='force_change';const canClose=!forceMandatory&&!isForceMode;return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-md p-6 text-slate-200"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center pb-4 border-b border-[#383838]"},/*#__PURE__*/React.createElement("h3",{className:"text-lg font-bold text-teal-400"},isForceMode?'⚠️ Bắt Buộc Đổi Mật Khẩu Khởi Tạo':activeTab==='login'?'🔐 Đăng Nhập Hệ Thống':'📝 Đăng Ký Tài Khoản'),canClose&&/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-slate-200"},"✕")),error&&/*#__PURE__*/React.createElement("div",{className:"mt-4 p-3 bg-red-900/40 border border-red-700 rounded-lg text-red-200 text-sm"},error),/*#__PURE__*/React.createElement("form",{onSubmit:handleSubmit,className:"mt-4 space-y-4"},isForceMode?/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("p",{className:"text-xs text-amber-400 bg-amber-950/60 p-2.5 border border-amber-800/80 rounded leading-relaxed"},"🔒 Tài khoản của bạn đang dùng mật khẩu khởi tạo mặc định. Để bảo mật hệ thống, bạn phải đổi mật khẩu mới trước khi tiếp tục."),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Mật khẩu hiện tại (Mặc định: admin123)"),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:"current-password",required:true,value:oldPassword,onChange:e=>setOldPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Mật khẩu mới"),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:"new-password",required:true,value:newPassword,onChange:e=>setNewPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"}))):/*#__PURE__*/React.createElement(React.Fragment,null,activeTab==='login'?/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Tên đăng nhập ",firstTime&&/*#__PURE__*/React.createElement("span",{className:"text-teal-400 font-normal"},"(Tùy chọn - Admin có thể bỏ trống)")),/*#__PURE__*/React.createElement("input",{type:"text",placeholder:"Mặc định: admin",value:username,onChange:e=>setUsername(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})):/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Tên đăng nhập"),/*#__PURE__*/React.createElement("input",{type:"text",required:true,value:username,onChange:e=>setUsername(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})),activeTab==='register'&&/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Email"),/*#__PURE__*/React.createElement("input",{type:"email",required:true,value:email,onChange:e=>setEmail(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold mb-1 text-slate-400"},"Mật khẩu ",activeTab==='login'&&firstTime&&/*#__PURE__*/React.createElement("span",{className:"text-amber-400 font-normal"},"(Lần đầu: admin123)")),/*#__PURE__*/React.createElement("input",{type:"password",autoComplete:activeTab==='login'?'current-password':'new-password',required:true,value:password,onChange:e=>setPassword(e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-2 text-sm focus:outline-none focus:border-teal-500"}))),/*#__PURE__*/React.createElement("button",{type:"submit",disabled:loading,className:"w-full py-2 bg-teal-600 hover:bg-teal-500 text-white font-semibold rounded-lg shadow transition duration-150"},loading?'Đang xác thực...':isForceMode?'Đổi Mật Khẩu Ngay':activeTab==='login'?'Đăng Nhập System':'Tạo Tài Khoản Mới'),activeTab==='login'&&!isForceMode&&firstTime&&/*#__PURE__*/React.createElement("button",{type:"button",onClick:()=>{setUsername('admin');setPassword('admin123');setError('');},className:"w-full mt-2 py-1.5 bg-amber-950/60 hover:bg-amber-900/80 text-amber-300 border border-amber-800/60 text-xs font-semibold rounded-lg transition flex items-center justify-center gap-1.5"},"🔑 Điền Nhanh Tài Khoản Admin (admin / admin123)")),!isForceMode&&/*#__PURE__*/React.createElement("div",{className:"mt-4 pt-4 border-t border-[#383838] text-center text-xs text-slate-400"},activeTab==='login'?/*#__PURE__*/React.createElement("span",null,"Chưa có tài khoản? ",/*#__PURE__*/React.createElement("button",{onClick:()=>setActiveTab('register'),className:"text-teal-400 hover:underline"},"Đăng ký ngay")):/*#__PURE__*/React.createElement("span",null,"Đã có tài khoản? ",/*#__PURE__*/React.createElement("button",{onClick:()=>setActiveTab('login'),className:"text-teal-400 hover:underline"},"Đăng nhập")))));};const AIConfigModal=({isOpen,onClose,onConfigSaved})=>{if(!isOpen)return null;const defaultProvidersList=[{id:'openai_default',name:'OpenAI Official',provider_type:'openai',api_base_url:'https://api.openai.com/v1',api_key:'',model_name:'gpt-4o',temperature:0.7,is_active:true},{id:'openai_compat_default',name:'OpenAI Compatible (Ollama/LocalAI/DeepSeek)',provider_type:'openai_compatible',api_base_url:'http://localhost:11434/v1',api_key:'ollama',model_name:'deepseek-r1',temperature:0.7,is_active:false},{id:'anthropic_default',name:'Anthropic Claude',provider_type:'anthropic',api_base_url:'https://api.anthropic.com/v1',api_key:'',model_name:'claude-3-5-sonnet',temperature:0.7,is_active:false},{id:'gemini_default',name:'Google Gemini',provider_type:'gemini',api_base_url:'https://generativelanguage.googleapis.com',api_key:'',model_name:'gemini-1.5-pro',temperature:0.7,is_active:false}];const[providers,setProviders]=useState(defaultProvidersList);const[selectedId,setSelectedId]=useState('openai_default');const[msg,setMsg]=useState('');const[error,setError]=useState('');const[loading,setLoading]=useState(false);useEffect(()=>{if(isOpen)loadConfigs();},[isOpen]);const loadConfigs=async()=>{setLoading(true);setError('');try{const data=await window.SonicAPI.getAIConfigs();if(data&&data.providers){setProviders(data.providers);if(data.providers.length>0)setSelectedId(data.providers[0].id);}}catch(err){setError(err.message||'Lỗi nạp cấu hình AI');}finally{setLoading(false);}};const handleSave=async e=>{e.preventDefault();setMsg('');setError('');setLoading(true);try{const res=await window.SonicAPI.saveAIConfigs(providers);setMsg(res.message||'Đã lưu cấu hình AI Providers thành công!');if(onConfigSaved)onConfigSaved(providers);}catch(err){setError(err.message||'Lỗi khi lưu cấu hình AI');}finally{setLoading(false);}};const updateProviderField=(id,field,value)=>{setProviders(prev=>prev.map(p=>p.id===id?{...p,[field]:value}:p));};const activeProvider=providers.find(p=>p.id===selectedId)||providers[0];return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm select-none"},/*#__PURE__*/React.createElement("div",{className:"bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-full max-w-2xl p-6 text-slate-200"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center pb-4 border-b border-[#383838]"},/*#__PURE__*/React.createElement("h3",{className:"text-lg font-bold text-cyan-400 flex items-center gap-2"},"🤖 Quản Lý & Cấu Hình AI Providers"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-slate-200"},"✕")),msg&&/*#__PURE__*/React.createElement("div",{className:"mt-3 p-2 bg-emerald-950/60 border border-emerald-700 text-emerald-300 rounded text-xs"},msg),error&&/*#__PURE__*/React.createElement("div",{className:"mt-3 p-2 bg-red-950/60 border border-red-700 text-red-300 rounded text-xs"},error),/*#__PURE__*/React.createElement("div",{className:"mt-4 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"space-y-1.5 border-r border-[#383838] pr-3"},/*#__PURE__*/React.createElement("span",{className:"text-xs uppercase font-bold text-slate-400 block mb-2"},"Providers"),providers.map(p=>/*#__PURE__*/React.createElement("button",{key:p.id,tabIndex:0,onClick:()=>setSelectedId(p.id),onKeyDown:e=>{if(e.key===' '){e.preventDefault();updateProviderField(p.id,'is_active',!p.is_active);}},className:`w-full text-left px-3 py-2 rounded-lg text-xs font-semibold flex items-center justify-between transition ${selectedId===p.id?'bg-cyan-600 text-white shadow':'bg-[#1e1e1e] text-slate-300 hover:bg-[#2e2e2e]'}`},/*#__PURE__*/React.createElement("span",{className:"truncate"},p.name),p.is_active&&/*#__PURE__*/React.createElement("span",{className:"w-2 h-2 rounded-full bg-emerald-400"}))),/*#__PURE__*/React.createElement("div",{className:"flex gap-1 mt-2"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();const rearrangeNewId='provider_'+Date.now();setProviders(prev=>[...prev,{id:rearrangeNewId,name:'New Provider',provider_type:'openai_compatible',api_base_url:'https://api.openai.com/v1',api_key:'',model_name:'gpt-4o-mini',temperature:0.7,is_active:false}]);setSelectedId(rearrangeNewId);},className:"flex-1 px-2 py-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs font-bold rounded"},"+ Thêm"),providers.length>1&&/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();if(confirm(`Xóa provider "${providers.find(p=>p.id===selectedId)?.name}"?`)){setProviders(prev=>{const filtered=prev.filter(p=>p.id!==selectedId);if(filtered.length>0)setSelectedId(filtered[0].id);return filtered;});}},className:"px-2 py-1 bg-red-800 hover:bg-red-700 text-white text-xs font-bold rounded"},"Xóa"),providers.length>1&&/*#__PURE__*/React.createElement("button",{onClick:function(e){e.stopPropagation();var provs=providers.slice();var pos=-1;for(var pi=0;pi0){var tmp=provs[pos];provs[pos]=provs[pos-1];provs[pos-1]=tmp;setProviders(provs);}},className:"px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded",title:"Di chuyển lên"},"▲"),providers.length>1&&/*#__PURE__*/React.createElement("button",{onClick:function(e){e.stopPropagation();var provs=providers.slice();var pos=-1;for(var pi=0;piupdateProviderField(activeProvider.id,'name',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"API Base URL (Endpoint)"),/*#__PURE__*/React.createElement("input",{type:"text",value:activeProvider.api_base_url||'',onChange:e=>updateProviderField(activeProvider.id,'api_base_url',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-cyan-300 focus:outline-none focus:border-cyan-500",placeholder:"https://api.openai.com/v1"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"API Key Cá Nhân"),/*#__PURE__*/React.createElement("input",{type:"password",value:activeProvider.api_key||'',onChange:e=>updateProviderField(activeProvider.id,'api_key',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-slate-200 focus:outline-none focus:border-cyan-500",placeholder:"sk-..."})),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-3"},/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"Model Name"),/*#__PURE__*/React.createElement("input",{type:"text",value:activeProvider.model_name||'',onChange:e=>updateProviderField(activeProvider.id,'model_name',e.target.value),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs font-mono text-amber-300 focus:outline-none focus:border-cyan-500"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("label",{className:"block text-xs font-semibold text-slate-400 mb-1"},"Temperature"),/*#__PURE__*/React.createElement("input",{type:"number",step:"0.1",min:"0",max:"2",value:activeProvider.temperature??0.7,onChange:e=>updateProviderField(activeProvider.id,'temperature',parseFloat(e.target.value)),className:"w-full bg-[#1e1e1e] border border-[#383838] rounded px-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-cyan-500"}))),/*#__PURE__*/React.createElement("div",{className:"pt-2 flex items-center justify-between"},/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-2 cursor-pointer text-xs text-slate-300"},/*#__PURE__*/React.createElement("input",{type:"checkbox",checked:activeProvider.is_active,onChange:e=>updateProviderField(activeProvider.id,'is_active',e.target.checked),className:"rounded accent-cyan-500"}),"Kích hoạt Provider này"),/*#__PURE__*/React.createElement("button",{type:"submit",disabled:loading,className:"px-4 py-1.5 bg-cyan-600 hover:bg-cyan-500 text-white font-semibold text-xs rounded-lg shadow transition"},loading?'Đang lưu...':'Lưu Cấu Hình AI'))))));};const PluginManagerModal=({isOpen,onClose,pluginsData,onInsertInstrument})=>{if(!isOpen)return null;const[localData,setLocalData]=React.useState(pluginsData);const[sfUploadStatus,setSfUploadStatus]=React.useState('');const[sfToDelete,setSfToDelete]=React.useState(null);const[pmDirs,setPmDirs]=React.useState([]);const[pmScanning,setPmScanning]=React.useState(false);const[pmScanResult,setPmScanResult]=React.useState('');const[pmScanData,setPmScanData]=React.useState(null);// { vst_found, soundfonts }
+// Instrument bên trong mỗi 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().then(data=>setLocalData(data)).catch(()=>setLocalData({vst_instruments:[],soundfonts:[]}));window.SonicAPI.getPluginDirs().then(d=>setPmDirs(d.plugin_dirs||[])).catch(()=>{});setTimeout(()=>{try{window.lucide.createIcons();}catch(e){}},50);}},[isOpen]);// Folder picker:
// 1) Neu page duoc Tauri serve (__TAURI__ co) -> dialog plugin invoke.
// 2) Binh thuong UI chay tren http://127.0.0.1:8000 (engine) -> __TAURI__
// KHONG co (Tauri chi inject vao trang no serve; window.prompt cung
@@ -377,14 +379,20 @@ const openPluginPicker=async()=>{setPmPicker({path:null,dirs:null,parent:null,ro
try{const d=await window.SonicAPI.pickPluginDir();if(d&&typeof d.path==='string'&&d.path){addDir(d.path);return;}}catch(e){/* fallthrough */}// 2) Tauri dialog trực tiếp (chỉ khi page được Tauri serve)
if(window.__TAURI__&&window.__TAURI__.core&&window.__TAURI__.core.invoke){const sel=await window.__TAURI__.core.invoke('plugin:dialog|open',{options:{directory:true,multiple:false}});if(typeof sel==='string'&&sel){addDir(sel);return;}}// 3) Trình duyệt thư mục in-app (backend) — fallback mọi OS
await openPluginPicker();return;}catch(e){// 4) Cuối cùng: prompt nhập tay (browser thuần, không phải WebView2)
-try{const manual=window.prompt('Nhập đường dẫn thư mục plugin (VST / SoundFont):');if(manual&&manual.trim())addDir(manual.trim());}catch(e2){showToast('Browse failed: '+(e.message||e),'error');}}};const removePluginDir=dir=>{setPmDirs(prev=>prev.filter(d=>d!==dir));};// Lưu dirs (user override) + scan cả 2 thư mục → refresh list + catalog.
+try{const manual=window.prompt('Nhập đường dẫn thư mục plugin (VST / SoundFont):');if(manual&&manual.trim())addDir(manual.trim());}catch(e2){showToast('Browse failed: '+(e.message||e),'error');}}};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} → hiển 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 — bản 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 thấy → user tự chọn thư mục
+// chứa 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ư mục → refresh list + catalog.
const saveAndScanDirs=async()=>{setPmScanning(true);setPmScanResult('');setPmScanData(null);try{await window.SonicAPI.savePluginDirs({plugin_dirs:pmDirs});const scan=await window.SonicAPI.scanPluginDirs();setPmScanData({vst_found:scan.vst_found||[],soundfonts:scan.soundfonts||[]});const data=await window.SonicAPI.listPlugins();setLocalData(data);try{const cat=await window.SonicAPI.getSoundfontCatalog();window.__soundfontCatalog=cat;}catch(_){}setPmScanResult(`VST: ${scan.vst_count||0} | SoundFonts: ${scan.soundfont_count||0}`);showToast(`Scan xong: ${scan.vst_count||0} VST, ${scan.soundfont_count||0} SoundFonts.`,'success');}catch(err){setPmScanResult('Scan failed: '+(err.message||err));showToast('Scan failed: '+(err.message||err),'error');}finally{setPmScanning(false);}};const handleUploadSF=async e=>{const file=e.target.files?.[0];if(!file)return;setSfUploadStatus('Uploading...');try{const result=await window.SonicAPI.uploadSoundFont(file);setSfUploadStatus('Uploaded: '+result.name);// Refresh plugin list and catalog
const data=await window.SonicAPI.listPlugins();setLocalData(data);try{const cat=await window.SonicAPI.getSoundfontCatalog();window.__soundfontCatalog=cat;}catch(_){}}catch(err){setSfUploadStatus('Error: '+err.message);}};const[pmTab,setPmTab]=React.useState('soundfont');return React.createElement('div',{className:'fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm',onClick:onClose},React.createElement('div',{className:'bg-[#1e1e1e] border border-[#383838] rounded-xl shadow-2xl w-full max-w-3xl p-0 text-slate-200 overflow-hidden flex flex-col relative',style:{maxHeight:'80vh'},onClick:e=>e.stopPropagation()},// ── In-app folder picker (Plugin Directories) ─────────────────────
pmPicker&&React.createElement('div',{className:'absolute inset-0 z-10 bg-[#171717]/97 flex flex-col',style:{padding:16}},React.createElement('div',{className:'flex items-center justify-between mb-2'},React.createElement('div',{className:'text-xs font-bold text-violet-300 uppercase'},'Chọn thư mục plugin'),React.createElement('button',{onClick:()=>setPmPicker(null),className:'text-zinc-500 hover:text-zinc-200 transition'},React.createElement('i',{'data-lucide':'x',className:'w-4 h-4'}))),React.createElement('div',{className:'flex items-center gap-2 mb-2'},React.createElement('button',{onClick:()=>{if(pmPicker.parent)browsePluginDir(pmPicker.parent);},disabled:!pmPicker.parent,className:'px-2 py-1 bg-zinc-800 hover:bg-zinc-700 rounded text-[11px] text-zinc-300 disabled:opacity-30 shrink-0'},'Lên'),React.createElement('div',{className:'flex-1 text-[11px] text-zinc-400 font-mono truncate',title:pmPicker.path||''},pmPicker.path||(pmPicker.loading?'Đang tải...':'My Computer'))),pmPicker.loading?React.createElement('div',{className:'flex-1 flex items-center justify-center text-zinc-500 text-xs'},'Đang tải...'):pmPicker.dirs?pmPicker.dirs.length===0?React.createElement('div',{className:'flex-1 flex items-center justify-center text-zinc-600 text-xs italic'},'Thư mục trống'):React.createElement('div',{className:'flex-1 overflow-y-auto space-y-1'},pmPicker.dirs.map((d,i)=>React.createElement('div',{key:'pd_'+i,className:'flex items-center gap-2 px-2 py-1.5 rounded hover:bg-violet-900/30 cursor-pointer group',onClick:()=>browsePluginDir(d.path)},React.createElement('i',{'data-lucide':'folder',className:'w-3.5 h-3.5 text-amber-500/80 shrink-0'}),React.createElement('span',{className:'flex-1 text-[11px] text-zinc-300 truncate'},d.name),React.createElement('i',{'data-lucide':'chevron-right',className:'w-3 h-3 text-zinc-600 group-hover:text-violet-400 shrink-0'})))):pmPicker.roots?pmPicker.roots.length===0?React.createElement('div',{className:'flex-1 flex items-center justify-center text-zinc-600 text-xs'},'Không tìm thấy ổ đĩa'):React.createElement('div',{className:'flex-1 overflow-y-auto space-y-1'},pmPicker.roots.map((r,i)=>React.createElement('div',{key:'pr_'+i,className:'flex items-center gap-2 px-2 py-1.5 rounded hover:bg-violet-900/30 cursor-pointer',onClick:()=>browsePluginDir(r.path)},React.createElement('i',{'data-lucide':'hard-drive',className:'w-3.5 h-3.5 text-cyan-500/80 shrink-0'}),React.createElement('span',{className:'flex-1 text-[11px] text-zinc-300'},r.name||r.path)))):null,React.createElement('div',{className:'flex items-center justify-end gap-2 mt-2 pt-2 border-t border-[#383838]'},React.createElement('button',{onClick:()=>setPmPicker(null),className:'px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-white text-xs rounded'},'Hủy'),React.createElement('button',{onClick:confirmPluginDir,disabled:!pmPicker.path,className:'px-3 py-1.5 bg-violet-700 hover:bg-violet-600 text-white text-xs font-semibold rounded disabled:opacity-30'},'Chọn thư mục này'))),// Header
React.createElement('div',{className:'flex items-center justify-between px-5 py-3 bg-[#252525] border-b border-[#383838]'},React.createElement('h3',{className:'text-base font-bold text-cyan-400 flex items-center gap-2'},React.createElement('i',{'data-lucide':'zap',className:'w-4 h-4'}),'Plugin Manager (SoundFont / VSTi)'),React.createElement('button',{onClick:onClose,className:'text-zinc-500 hover:text-zinc-200 transition'},React.createElement('i',{'data-lucide':'x',className:'w-4 h-4'}))),// Left-right body
React.createElement('div',{className:'flex flex-1 overflow-hidden',style:{minHeight:'300px'}},// Left sidebar
React.createElement('div',{className:'w-40 shrink-0 border-r border-[#383838] bg-[#1a1a1a] p-3 flex flex-col gap-2'},['vst','soundfont'].map(tab=>React.createElement('button',{key:tab,onClick:()=>setPmTab(tab),className:`w-full py-2 text-xs font-bold rounded transition border ${pmTab===tab?tab==='vst'?'bg-violet-900 border-violet-700 text-violet-200':'bg-amber-900 border-amber-700 text-amber-200':'bg-zinc-800 border-transparent text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700'} flex items-center gap-2 px-3`},React.createElement('i',{'data-lucide':tab==='vst'?'cpu':'music',className:'w-3.5 h-3.5'}),tab==='vst'?'VST Instruments':'SoundFonts'))),// Right content
-React.createElement('div',{className:'flex-1 overflow-y-auto p-4 bg-[#1e1e1e]'},!localData?React.createElement('div',{className:'flex items-center justify-center h-full text-zinc-500 text-xs'},'Loading...'):React.createElement('div',{className:'space-y-2'},pmTab==='vst'?localData.vst_instruments?.length===0?React.createElement('div',{className:'flex items-center justify-center h-32 text-zinc-500 text-xs'},'No VST instruments found on server.'):localData.vst_instruments.map((v,i)=>React.createElement('div',{key:i,className:'flex items-center justify-between bg-[#252525] px-4 py-3 rounded-lg border border-[#333] hover:border-violet-800/50 transition'},React.createElement('div',{className:'flex items-center gap-3'},React.createElement('div',{className:'w-8 h-8 rounded bg-violet-900/30 flex items-center justify-center'},React.createElement('i',{'data-lucide':'cpu',className:'w-4 h-4 text-violet-400'})),React.createElement('div',null,React.createElement('div',{className:'text-xs font-semibold text-slate-200'},v.name||v.id),React.createElement('div',{className:'text-[10px] text-zinc-500'},v.type||'VST3'))),React.createElement('span',{className:'text-[10px] bg-violet-950/40 text-violet-400 px-2 py-0.5 rounded-full border border-violet-800/30'},v.type||'VST3'))):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',{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'},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'},React.createElement('i',{'data-lucide':'music',className:'w-4 h-4 text-amber-400'})),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:'flex items-center gap-2'},React.createElement('button',{onClick:()=>setSfToDelete(sf),className:'text-[10px] text-zinc-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition px-2 py-1'},'Delete'))))),// Plugin directories section (folder picker + save + scan)
+React.createElement('div',{className:'flex-1 overflow-y-auto p-4 bg-[#1e1e1e]'},!localData?React.createElement('div',{className:'flex items-center justify-center h-full text-zinc-500 text-xs'},'Loading...'):React.createElement('div',{className:'space-y-2'},pmTab==='vst'?localData.vst_instruments?.length===0?React.createElement('div',{className:'flex items-center justify-center h-32 text-zinc-500 text-xs'},'No VST instruments found on server.'):localData.vst_instruments.map((v,i)=>React.createElement('div',{key:i,className:'flex items-center justify-between bg-[#252525] px-4 py-3 rounded-lg border border-[#333] hover:border-violet-800/50 transition'},React.createElement('div',{className:'flex items-center gap-3'},React.createElement('div',{className:'w-8 h-8 rounded bg-violet-900/30 flex items-center justify-center'},React.createElement('i',{'data-lucide':'cpu',className:'w-4 h-4 text-violet-400'})),React.createElement('div',null,React.createElement('div',{className:'text-xs font-semibold text-slate-200'},v.name||v.id),React.createElement('div',{className:'text-[10px] text-zinc-500'},v.type||'VST3'))),React.createElement('span',{className:'text-[10px] bg-violet-950/40 text-violet-400 px-2 py-0.5 rounded-full border border-violet-800/30'},v.type||'VST3'))):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)=>{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:'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'},React.createElement('i',{'data-lucide':'music',className:'w-4 h-4 text-amber-400'})),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)+(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: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',{className:'pt-4 mt-4 border-t border-[#383838]'},React.createElement('div',{className:'flex items-center justify-between mb-2'},React.createElement('h4',{className:'text-xs font-bold text-zinc-400 uppercase'},'Plugin Directories'),React.createElement('button',{className:'px-3 py-1.5 bg-violet-800 hover:bg-violet-700 text-white text-xs font-semibold rounded transition flex items-center gap-1 shrink-0',title:'Add plugin directory (VST / SoundFont)',onClick:pickPluginFolder},React.createElement('i',{'data-lucide':'plus',className:'w-3 h-3'}),'Add Directory')),pmDirs.length===0&&React.createElement('p',{className:'text-[10px] text-zinc-600 mb-2 italic'},'Chưa có thư mục nào. Nhấn Add Directory để chọn thư mục chứa VST / SoundFont.'),pmDirs.map((dir,idx)=>React.createElement('div',{key:'pdir_'+idx,className:'flex items-center gap-2 mb-1.5 bg-zinc-800/70 border border-zinc-700 rounded px-2 py-1.5'},React.createElement('button',{className:'text-zinc-500 hover:text-red-400 transition shrink-0',title:'Remove directory',onClick:()=>removePluginDir(dir)},React.createElement('i',{'data-lucide':'x',className:'w-3.5 h-3.5'})),React.createElement('span',{className:'flex-1 text-[11px] text-zinc-300 font-mono truncate',title:dir},dir),React.createElement('i',{'data-lucide':'folder',className:'w-3 h-3 text-zinc-600 shrink-0'}))),React.createElement('div',{className:'flex gap-2 items-center mt-2'},React.createElement('button',{className:'px-4 py-2 bg-emerald-800 hover:bg-emerald-700 text-white text-xs font-semibold rounded transition flex items-center gap-1',onClick:saveAndScanDirs},React.createElement('i',{'data-lucide':'search',className:'w-3 h-3'}),'Scan'),pmScanning&&React.createElement('span',{className:'text-[10px] text-emerald-400'},'Scanning...'),pmScanResult&&React.createElement('span',{className:'text-[10px] text-zinc-400'},pmScanResult)),pmScanData&&React.createElement('div',{className:'mt-3 space-y-2 max-h-40 overflow-y-auto'},React.createElement('div',{className:'text-[10px] font-bold text-violet-300 uppercase flex items-center gap-1'},React.createElement('i',{'data-lucide':'cpu',className:'w-3 h-3'}),'VST Instruments ('+pmScanData.vst_found.length+')'),pmScanData.vst_found.length===0?React.createElement('p',{className:'text-[10px] text-zinc-600 italic'},'Không tìm thấy VST.'):pmScanData.vst_found.map((v,i)=>React.createElement('div',{key:'sv_'+i,className:'flex items-center gap-2 text-[11px] text-zinc-300'},React.createElement('span',{className:'w-16 shrink-0 text-zinc-500 font-mono text-[9px] truncate'},v.type||'VST'),React.createElement('span',{className:'truncate'},v.name),React.createElement('span',{className:'text-[9px] text-zinc-600 font-mono truncate ml-auto'},v.dir))),React.createElement('div',{className:'text-[10px] font-bold text-amber-300 uppercase flex items-center gap-1 mt-2'},React.createElement('i',{'data-lucide':'music',className:'w-3 h-3'}),'SoundFonts ('+pmScanData.soundfonts.length+')'),pmScanData.soundfonts.length===0?React.createElement('p',{className:'text-[10px] text-zinc-600 italic'},'Không tìm thấy SoundFont.'):pmScanData.soundfonts.map((s,i)=>React.createElement('div',{key:'ss_'+i,className:'flex items-center gap-2 text-[11px] text-zinc-300'},React.createElement('span',{className:'truncate'},s.name),React.createElement('span',{className:'text-[9px] text-zinc-600 font-mono truncate ml-auto'},s.dir))))),// Upload section (bottom of right panel)
React.createElement('div',{className:'pt-4 mt-4 border-t border-[#383838]'},React.createElement('h4',{className:'text-xs font-bold text-zinc-400 mb-3 uppercase'},pmTab==='vst'?'Add VST Directory':'Upload SoundFont'),pmTab==='vst'?React.createElement('div',{className:'flex gap-2'},React.createElement('input',{type:'text',placeholder:'/opt/daw_engine/vst3',className:'flex-1 bg-zinc-800 border border-zinc-700 rounded px-3 py-2 text-xs text-zinc-300 focus:outline-none focus:border-violet-600'}),React.createElement('button',{className:'px-4 py-2 bg-violet-800 hover:bg-violet-700 text-white text-xs font-semibold rounded transition',onClick:async()=>{try{const data=await window.SonicAPI.listPlugins();setLocalData(data);showToast('Scanned VST directory.','info');}catch(err){showToast('Scan failed: '+err.message,'error');}}},'Scan')):React.createElement('div',{className:'space-y-2'},React.createElement('label',{className:'flex items-center gap-3 px-4 py-3 border-2 border-dashed border-zinc-700 rounded-lg cursor-pointer hover:border-amber-600/50 bg-zinc-800/40 transition'},React.createElement('i',{'data-lucide':'upload',className:'w-5 h-5 text-zinc-400'}),React.createElement('span',{className:'text-xs text-zinc-400'},'Click to upload .sf2 / .sf3 file'),React.createElement('input',{type:'file',accept:'.sf2,.sf3',onChange:async e=>{const file=e.target.files?.[0];if(!file)return;setSfUploadStatus('Uploading...');try{await window.SonicAPI.uploadSoundFont(file);setSfUploadStatus('Uploaded: '+file.name);const data=await window.SonicAPI.listPlugins();setLocalData(data);}catch(err){setSfUploadStatus('Error: '+err.message);}},className:'hidden'})),sfUploadStatus&&React.createElement('p',{className:'text-[10px] text-zinc-500'},sfUploadStatus)))))),// Status bar at bottom
React.createElement('div',{className:'px-5 py-2 bg-[#1a1a1a] border-t border-[#383838] flex items-center justify-between text-[10px] text-zinc-500'},React.createElement('span',null,'VST: ',localData?.vst_instruments?.length||0,' | SoundFonts: ',localData?.soundfonts?.length||0),React.createElement('span',null,'Last scanned: ',new Date().toLocaleTimeString())),// Delete confirmation modal
@@ -672,7 +680,7 @@ isLoopingRef.current=next;const cur=selectedRef.current;const st=playStateRef.cu
// the loop points to the current selection so it loops continuously
// over the selected region until Stop is pressed.
if(next&&st.source.buffer){if(hasSelection){st.source.loopStart=Math.min(sStart,sEnd);st.source.loopEnd=Math.max(sStart,sEnd);}else{st.source.loopStart=0;st.source.loopEnd=st.source.buffer.duration;}}}if(next&&isMidiFile(cur)){// Re-schedule loop for the currently previewing MIDI file
-if(cur&&(cur.handle||cur.path||cur.file_id||cur.fileId)){selectTokenRef.current++;const token=selectTokenRef.current;try{if(window.SonicSF&&typeof window.SonicSF.stopAll==='function')window.SonicSF.stopAll();}catch(e){}playMidiPreview(cur,token);}}return next;});};const toggleRate=dir=>setRate(prev=>Math.max(0.25,Math.min(4.0,Math.round((prev+dir*0.1)*100)/100)));const togglePitch=dir=>setPitch(prev=>Math.max(-24,Math.min(24,prev+dir*0.5)));const selIsMidi=isMidiFile(selected);const selDur=fileDuration(selected);const selBpm=selected&&selected.bpm||tempo;return/*#__PURE__*/React.createElement("div",{ref:containerRef,className:"flex flex-col w-full h-full text-slate-900 overflow-hidden select-none",style:{fontFamily:"'Inter', sans-serif"}},/*#__PURE__*/React.createElement("div",{className:"h-8 bg-[#d4d0c8] border-b border-[#808080] px-2 flex items-center justify-between text-xs shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 flex-1 mr-2"},/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Back",onClick:()=>folder==='computer'?goComputerParent():setFolder('library')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-arrow-left"})),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Forward",onClick:()=>setFolder('uploads')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-arrow-right"})),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Up Directory",onClick:()=>folder==='computer'?goComputerParent():setFolder('library')},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-arrow-up"})),/*#__PURE__*/React.createElement("button",{className:"w-5 h-5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm flex items-center justify-center text-[10px]",title:"Refresh",onClick:()=>{if(folder==='computer'){const node=computerTree[computerPath];if(node&&node.handle)browseComputerDir({name:computerPath.split('/').pop()||computerPath,path:computerPath,is_dir:true,handle:node.handle});else if(computerPath)browseComputerDir({name:computerPath.split(/[\\/]/).pop()||computerPath,path:computerPath,is_dir:true});}else if(window.SonicAPI&&window.SonicAPI.listMyFiles)window.SonicAPI.listMyFiles([]).then(d=>setUserFiles(d||[]));}},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-rotate-right"})),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex items-center bg-white border border-[#808080] h-5 px-1"},/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-folder-open text-[#d9a752] text-[11px] mr-1.5"}),/*#__PURE__*/React.createElement("span",{className:"flex-1 text-xs text-slate-800 truncate"},folder==='computer'?computerPath||'My Computer':folder==='library'?'Media Library':folder==='uploads'?'Uploads':'Processed'),/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"}))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center bg-white border border-[#808080] h-5 px-1 w-40"},/*#__PURE__*/React.createElement("input",{type:"text",placeholder:"Filter/Search...",value:filterText,onChange:e=>setFilterText(e.target.value),className:"w-full text-xs outline-none bg-transparent font-sans text-slate-800"}),/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-caret-down text-slate-600 text-[10px] ml-1"})),/*#__PURE__*/React.createElement("button",{className:"px-2 py-0.5 bg-[#e0e0e0] hover:bg-white border border-[#707070] rounded-sm text-[11px] flex items-center gap-1 font-semibold",onClick:()=>setViewMode(viewMode==='details'?'list':'details')},/*#__PURE__*/React.createElement("span",null,viewMode==='details'?'Details':'List')," ",/*#__PURE__*/React.createElement("i",{className:"fa-solid fa-caret-down text-[9px]"})))),/*#__PURE__*/React.createElement("div",{className:"flex-1 flex overflow-hidden min-h-0 relative"},/*#__PURE__*/React.createElement("div",{className:"flex bg-white border border-[#808080] m-1 mr-0 text-xs select-none shrink-0 overflow-hidden"},/*#__PURE__*/React.createElement("div",{ref:treePaneRef,className:"overflow-y-auto p-1",style:{width:treeWidth+'px',minWidth:treeWidth+'px',maxWidth:treeWidth+'px'}},/*#__PURE__*/React.createElement("div",{className:"space-y-0.5 font-sans"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 px-1 py-0.5 text-slate-700"},/*#__PURE__*/React.createElement("span",{className:"w-3"}),"