FIX: Carla sử dụng bridge
This commit is contained in:
+131
-1
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1,51 @@
|
||||
# SonicForge System API — capabilities: frontend gọi 1 lần lúc boot để biết
|
||||
# môi trường (desktop Windows / docker headless) và bật/tắt tính năng tương ứng.
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from app.core.runtime import capabilities, save_carla_path
|
||||
from app.api.v1.auth import get_current_user, enforce_password_changed
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/capabilities")
|
||||
async def get_capabilities():
|
||||
"""Khả năng của môi trường hiện tại (public — cần trước khi đăng nhập).
|
||||
|
||||
- runtime: "desktop" (server + client cùng 1 máy Windows/macOS) |
|
||||
"headless" (docker server + browser UI)
|
||||
- features.carla_local: có Carla trên máy này → hiện nút "Mở trong Carla"
|
||||
- features.preset_upload: luôn True (upload .vstpreset qua web UI)
|
||||
- features.preview_mode: "quick_render" (pedalboard render clip ngắn —
|
||||
âm thật giống export) | "wasm" (Preview Synth trong browser)
|
||||
"""
|
||||
return capabilities()
|
||||
|
||||
|
||||
class CarlaPathRequest(BaseModel):
|
||||
"""Định vị Carla (bản portable zip không cài đặt/PATH). Chấp nhận đường
|
||||
dẫn tới carla.exe HOẶC thư mục chứa carla.exe — resolve và lưu config."""
|
||||
carla_path: str
|
||||
carla_dir: Optional[str] = None # tương thích ngược: tên cũ của carla_path
|
||||
|
||||
|
||||
@router.post("/carla-path")
|
||||
async def set_carla_path(req: CarlaPathRequest, current_user: dict = Depends(get_current_user)):
|
||||
"""Lưu vị trí carla.exe do user chọn (Plugin Manager → Định vị Carla...).
|
||||
|
||||
Cần thiết vì bản Carla Windows là bộ file zip portable — không có installer
|
||||
cũng không dùng biến môi trường PATH, nên heuristic không tìm thấy."""
|
||||
enforce_password_changed(current_user)
|
||||
target = (req.carla_path or req.carla_dir or "").strip()
|
||||
if not target:
|
||||
raise HTTPException(status_code=400, detail="Thiếu đường dẫn Carla")
|
||||
exe = save_carla_path(target)
|
||||
if not exe:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Không tìm thấy carla.exe trong đường dẫn đã chọn. Hãy chọn "
|
||||
"thư mục chứa carla.exe (bản portable giải nén) hoặc chính file carla.exe.",
|
||||
)
|
||||
return {"success": True, "carla_path": exe, **capabilities()}
|
||||
|
||||
+31
-3
@@ -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()
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.core.vst_engine import (
|
||||
DecentSamplerManager,
|
||||
HAS_PEDALBOARD,
|
||||
HAS_PYFLUIDSYNTH,
|
||||
apply_preset_to_plugin,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -223,6 +224,21 @@ class PythonRenderEngine:
|
||||
)
|
||||
elif vst and HAS_PEDALBOARD:
|
||||
from pedalboard import Pedalboard
|
||||
# Preset bridge Carla → pedalboard: preset_id
|
||||
# (thư viện storage/presets), preset_path (file)
|
||||
# hoặc preset_data (base64 .vstpreset nhúng trong
|
||||
# project). Cùng sample rate → âm render = âm đã
|
||||
# chỉnh trong Carla.
|
||||
try:
|
||||
se2 = track.get("synth_engine", {}) or {}
|
||||
apply_preset_to_plugin(
|
||||
vst,
|
||||
preset_id=se2.get("preset_id") or track.get("preset_id"),
|
||||
preset_path=se2.get("preset_path") or track.get("preset_path"),
|
||||
preset_data_b64=se2.get("preset_data") or track.get("preset_data"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[RenderEngine] Preset apply failed: %s", e)
|
||||
midi_messages = PluginManager.midi_events_to_messages(
|
||||
midi_events, bpm, self.sample_rate,
|
||||
bank=soundfont_bank, program=soundfont_program
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
# SonicForge Runtime Profile — phát hiện môi trường chạy (desktop Windows /
|
||||
# docker headless) và khả năng của máy, để app TỰ CHỌN cách xử lý:
|
||||
# - Windows desktop (server + client cùng 1 máy): Carla local để preview +
|
||||
# chỉnh preset → pedalboard render (Hướng A)
|
||||
# - Docker / Linux headless (server + browser UI): soundfont + VSTi mở được
|
||||
# từ storage mount; preset upload qua browser; không có GUI local.
|
||||
#
|
||||
# Ưu tiên: SF_RUNTIME env override > heuristic (platform + display + docker).
|
||||
# Module CHỈ dùng stdlib + app.config (không kéo pedalboard/numpy) để import
|
||||
# nhẹ và hoạt động trong mọi tiến trình (web/worker/celery).
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import functools
|
||||
|
||||
from app.config import settings
|
||||
|
||||
SF_RUNTIME_ENV = "SF_RUNTIME" # auto | desktop | headless
|
||||
SF_DOCKER_ENV = "SF_DOCKER" # 1 = chạy trong container Docker
|
||||
CARLA_CONFIG_FILE = "carla_path.json" # storage/carla_path.json — user định vị
|
||||
# Bản Carla portable (zip) có thể giải nén ở BẤT KỲ ĐÂU — PATH/Program Files
|
||||
# không đủ. User tự chọn thư mục chứa carla.exe qua Plugin Manager → lưu file
|
||||
# này (ưu tiên cao nhất khi detect), kèm tìm kiếm nông Downloads/Desktop.
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
return os.name == "nt"
|
||||
|
||||
|
||||
def _is_macos() -> bool:
|
||||
return sys.platform == "darwin"
|
||||
|
||||
|
||||
def _is_linux() -> bool:
|
||||
return not _is_windows() and not _is_macos()
|
||||
|
||||
|
||||
def _in_docker() -> bool:
|
||||
if os.environ.get(SF_DOCKER_ENV) == "1":
|
||||
return True
|
||||
# Marker chuẩn của Docker (không tồn tại trên máy host thường)
|
||||
try:
|
||||
return os.path.exists("/.dockerenv")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _has_display() -> bool:
|
||||
"""Có môi trường GUI hiển thị được hay không.
|
||||
|
||||
Windows/macOS luôn có (desktop). Linux cần biến DISPLAY (X11) — nếu chạy
|
||||
Docker không có DISPLAY → headless."""
|
||||
if _is_windows() or _is_macos():
|
||||
return True
|
||||
return bool(os.environ.get("DISPLAY"))
|
||||
|
||||
|
||||
def _tauri_bridge_ready() -> bool:
|
||||
"""App desktop Tauri viết marker file lúc setup (xem plugins.py
|
||||
_pick_dir_via_tauri_bridge) — dùng để nhận diện bản desktop app."""
|
||||
try:
|
||||
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||
return os.path.exists(os.path.join(root, "SonicForgeDAW", "ipc", "tauri_bridge_ready"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _carla_config_path() -> str:
|
||||
return os.path.join(settings.STORAGE_DIR, CARLA_CONFIG_FILE)
|
||||
|
||||
|
||||
def get_configured_carla() -> str:
|
||||
"""Đường dẫn carla.exe do USER tự định vị (lưu trong storage/carla_path.json).
|
||||
|
||||
File lưu đường dẫn tới carla.exe (đã resolve) hoặc thư mục chứa — nếu là
|
||||
thư mục, tìm carla.exe bên trong (độ sâu ≤ 2). Đây là cách bắt buộc có cho
|
||||
bản Carla portable (zip) giải nén ở vị trí bất kỳ, không cài đặt/PATH."""
|
||||
try:
|
||||
with open(_carla_config_path(), "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
return ""
|
||||
p = (data.get("carla_path") or "").strip()
|
||||
if not p:
|
||||
return ""
|
||||
if os.path.isfile(p) and os.path.basename(p).lower() in ("carla.exe", "carla-single.exe", "carla"):
|
||||
return p
|
||||
if os.path.isdir(p):
|
||||
try:
|
||||
for root, dirs, files in os.walk(p):
|
||||
depth = root[len(p):].count(os.sep)
|
||||
if depth >= 2:
|
||||
dirs[:] = []
|
||||
continue
|
||||
for f in files:
|
||||
if f.lower() == "carla.exe":
|
||||
return os.path.join(root, f)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def save_carla_path(path: str) -> str:
|
||||
"""Lưu vị trí Carla do user chọn (Plugin Manager → Định vị Carla...).
|
||||
|
||||
Chấp nhận: đường dẫn tới carla.exe, hoặc thư mục chứa carla.exe (portable
|
||||
zip). Resolve về đường dẫn exe hợp lệ → ghi config → xóa cache detect.
|
||||
Trả đường dẫn exe, hoặc '' nếu không hợp lệ."""
|
||||
path = (path or "").strip().strip('"').strip()
|
||||
exe = ""
|
||||
if os.path.isfile(path) and os.path.basename(path).lower() in ("carla.exe", "carla-single.exe", "carla"):
|
||||
exe = path
|
||||
elif os.path.isdir(path):
|
||||
try:
|
||||
for root, dirs, files in os.walk(path):
|
||||
depth = root[len(path):].count(os.sep)
|
||||
if depth >= 2:
|
||||
dirs[:] = []
|
||||
continue
|
||||
for f in files:
|
||||
if f.lower() == "carla.exe":
|
||||
exe = os.path.join(root, f)
|
||||
break
|
||||
if exe:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not exe:
|
||||
return ""
|
||||
try:
|
||||
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
|
||||
with open(_carla_config_path(), "w", encoding="utf-8") as f:
|
||||
json.dump({"carla_path": exe, "carla_exe": os.path.basename(exe)}, f, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
return ""
|
||||
# Xóa cache detect/find_carla để capabilities phản ánh ngay
|
||||
invalidate_carla_cache()
|
||||
return exe
|
||||
|
||||
|
||||
def _carla_from_registry() -> str:
|
||||
"""Windows: Carla cài qua installer có thể ghi registry (best-effort)."""
|
||||
if not _is_windows():
|
||||
return ""
|
||||
try:
|
||||
import winreg
|
||||
for hive, key in (
|
||||
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Carla"),
|
||||
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Carla"),
|
||||
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Carla"),
|
||||
):
|
||||
try:
|
||||
with winreg.OpenKey(hive, key) as k:
|
||||
val, _ = winreg.QueryValueEx(k, "InstallPath")
|
||||
p = os.path.join(str(val), "carla.exe")
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _find_carla_exe_bounded(base: str, max_depth: int = 3) -> str:
|
||||
"""Quét nông tìm carla.exe (bản portable giải nén thường nằm Downloads/
|
||||
Desktop/Documents). Giới hạn độ sâu + bỏ qua thư mục lớn — KHÔNG quét
|
||||
toàn ổ đĩa."""
|
||||
if not base or not os.path.isdir(base):
|
||||
return ""
|
||||
skip = ("node_modules", ".cache", "AppData", ".git", "venv", ".venv",
|
||||
"__pycache__", "$RECYCLE.BIN", "Windows", "Program Files",
|
||||
"Program Files (x86)")
|
||||
try:
|
||||
for root, dirs, files in os.walk(base):
|
||||
depth = root[len(base):].count(os.sep) if root != base else 0
|
||||
if depth >= max_depth:
|
||||
dirs[:] = []
|
||||
continue
|
||||
for f in files:
|
||||
if f.lower() == "carla.exe":
|
||||
return os.path.join(root, f)
|
||||
dirs[:] = [d for d in dirs if d not in skip and not d.startswith(".")]
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _carla_from_shallow_search() -> str:
|
||||
"""Windows: tìm carla.exe trong Downloads/Desktop/Documents/Home."""
|
||||
if not _is_windows():
|
||||
return ""
|
||||
home = os.environ.get("USERPROFILE") or os.path.expanduser("~")
|
||||
bases = []
|
||||
for sub in ("Downloads", "Desktop", "Documents"):
|
||||
bases.append(os.path.join(home, sub))
|
||||
bases.append(home)
|
||||
for base in bases:
|
||||
hit = _find_carla_exe_bounded(base)
|
||||
if hit:
|
||||
return hit
|
||||
return ""
|
||||
|
||||
|
||||
def _carla_candidates() -> list:
|
||||
"""Các vị trí Carla — theo thứ tự ưu tiên:
|
||||
1) user định vị (config file — bản portable bắt buộc dùng cách này)
|
||||
2) PATH (shutil.which)
|
||||
3) registry (Windows, nếu cài qua installer)
|
||||
4) thư mục cài đặt chuẩn (Program Files...)
|
||||
5) quét nông Downloads/Desktop/Documents (Windows) / AppImage (Linux)"""
|
||||
cands = []
|
||||
configured = get_configured_carla()
|
||||
if configured:
|
||||
cands.append(configured)
|
||||
try:
|
||||
which = shutil.which("carla")
|
||||
if which:
|
||||
cands.append(which)
|
||||
except Exception:
|
||||
pass
|
||||
reg = _carla_from_registry()
|
||||
if reg:
|
||||
cands.append(reg)
|
||||
try:
|
||||
if _is_windows():
|
||||
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
lpf = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
||||
la = os.environ.get("LOCALAPPDATA", "")
|
||||
for base in (pf, lpf, la):
|
||||
for rel in ("Carla\\carla.exe", "Carla\\bin\\carla.exe", "Programs\\Carla\\carla.exe"):
|
||||
p = os.path.join(base, rel) if base else ""
|
||||
if p and os.path.isfile(p):
|
||||
cands.append(p)
|
||||
elif _is_linux():
|
||||
home = os.path.expanduser("~")
|
||||
for p in ("/usr/bin/carla", "/usr/local/bin/carla", "/opt/carla/carla"):
|
||||
if os.path.isfile(p):
|
||||
cands.append(p)
|
||||
try:
|
||||
for f in os.listdir(home):
|
||||
if f.lower().startswith("carla") and f.lower().endswith(".appimage"):
|
||||
cands.append(os.path.join(home, f))
|
||||
except Exception:
|
||||
pass
|
||||
elif _is_macos():
|
||||
for p in ("/Applications/Carla.app/Contents/MacOS/Carla", "/Applications/Carla.app/Contents/MacOS/carla"):
|
||||
if os.path.isfile(p):
|
||||
cands.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
shallow = _carla_from_shallow_search()
|
||||
if shallow:
|
||||
cands.append(shallow)
|
||||
# Dedup giữ thứ tự
|
||||
seen, out = set(), []
|
||||
for c in cands:
|
||||
if c not in seen:
|
||||
seen.add(c)
|
||||
out.append(c)
|
||||
return out
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def find_carla() -> str:
|
||||
"""Đường dẫn Carla đầu tiên tìm thấy, hoặc '' nếu chưa có.
|
||||
|
||||
Lưu ý license: Carla GPL-2.0+ — app KHÔNG bundle/nhúng, chỉ spawn tiến
|
||||
trình ngoài (user tự cài/giải nén) → không dính copyleft."""
|
||||
for c in _carla_candidates():
|
||||
return c
|
||||
return ""
|
||||
|
||||
|
||||
def invalidate_carla_cache():
|
||||
"""Xóa cache detect/find_carla — gọi sau khi user định vị Carla mới."""
|
||||
find_carla.cache_clear()
|
||||
detect.cache_clear()
|
||||
|
||||
|
||||
def _detect_platform() -> str:
|
||||
if _is_windows():
|
||||
return "windows"
|
||||
if _is_macos():
|
||||
return "darwin"
|
||||
return "linux"
|
||||
|
||||
|
||||
def _detect_runtime() -> str:
|
||||
"""auto → desktop nếu có GUI (Windows/macOS luôn; Linux cần DISPLAY và
|
||||
không phải docker), ngược lại headless. SF_RUNTIME override thắng."""
|
||||
override = (os.environ.get(SF_RUNTIME_ENV) or "auto").strip().lower()
|
||||
if override in ("desktop", "headless"):
|
||||
return override
|
||||
if _is_windows() or _is_macos():
|
||||
return "desktop"
|
||||
if _in_docker() or not _has_display():
|
||||
return "headless"
|
||||
return "desktop"
|
||||
|
||||
|
||||
def default_vst_dirs() -> list:
|
||||
"""Thư mục VST mặc định theo platform (vẫn ưu tiên env VST_DIR).
|
||||
|
||||
Windows: thư mục chuẩn VST3 của hệ thống; user thường khai báo thêm qua
|
||||
Plugins Manager (plugin_dirs.json)."""
|
||||
if settings.VST_DIR and settings.VST_DIR != "/opt/daw_engine/vst3":
|
||||
return [settings.VST_DIR]
|
||||
if _is_windows():
|
||||
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
return [os.path.join(pf, "Common Files", "VST3"), os.path.join(pf, "VSTPlugins")]
|
||||
if _is_macos():
|
||||
return ["/Library/Audio/Plug-Ins/VST3", os.path.expanduser("~/Library/Audio/Plug-Ins/VST3")]
|
||||
return ["/opt/daw_engine/vst3", os.path.expanduser("~/.vst3")]
|
||||
|
||||
|
||||
def default_soundfont_dirs() -> list:
|
||||
if settings.SOUNDFONT_DIR and settings.SOUNDFONT_DIR != "/opt/daw_engine/soundfonts":
|
||||
return [settings.SOUNDFONT_DIR]
|
||||
if _is_windows():
|
||||
root = os.environ.get("APPDATA") or os.path.expanduser("~")
|
||||
return [os.path.join(root, "SonicForgeDAW", "soundfonts")]
|
||||
if _is_macos():
|
||||
return [os.path.expanduser("~/Music/SonicForgeDAW/soundfonts")]
|
||||
return ["/opt/daw_engine/soundfonts", os.path.expanduser("~/.sf2")]
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def detect() -> dict:
|
||||
"""Detect 1 lần (cache toàn cục) — kết quả bất biến trong 1 tiến trình."""
|
||||
platform = _detect_platform()
|
||||
runtime = _detect_runtime()
|
||||
carla = find_carla() if runtime == "desktop" else ""
|
||||
return {
|
||||
"platform": platform,
|
||||
"runtime": runtime, # "desktop" | "headless"
|
||||
"docker": _in_docker(),
|
||||
"has_display": _has_display(),
|
||||
"tauri_bridge": _tauri_bridge_ready(),
|
||||
"carla_path": carla,
|
||||
"carla_local": bool(carla), # chỉ có ý nghĩa khi runtime=desktop
|
||||
"vst_render": _vst_render_available(),
|
||||
"default_vst_dirs": default_vst_dirs(),
|
||||
"default_soundfont_dirs": default_soundfont_dirs(),
|
||||
"preset_dir": os.path.join(settings.STORAGE_DIR, "presets"),
|
||||
}
|
||||
|
||||
|
||||
def _vst_render_available() -> bool:
|
||||
"""pedalboard có sẵn không (dùng find_spec — KHÔNG import pedalboard để
|
||||
tránh kéo JUCE lib; giống pattern _module_available trong vst_engine)."""
|
||||
import importlib.util
|
||||
try:
|
||||
return importlib.util.find_spec("pedalboard") is not None
|
||||
except (ImportError, AttributeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def capabilities() -> dict:
|
||||
"""Capabilities API — frontend gọi 1 lần lúc boot để bật/tắt tính năng."""
|
||||
d = detect()
|
||||
features = {
|
||||
"carla_local": d["carla_local"],
|
||||
"carla_path": d["carla_path"],
|
||||
"preset_upload": True, # cả 2 mode đều upload preset được
|
||||
"vst_render": d["vst_render"],
|
||||
"tauri_bridge": d["tauri_bridge"],
|
||||
# preview VSTi: quick_render (pedalboard render clip ngắn — âm thật,
|
||||
# giống export) là chuẩn cho cả 2 mode; không có realtime trong web UI.
|
||||
"preview_mode": "quick_render" if d["vst_render"] else "wasm",
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"runtime": d["runtime"],
|
||||
"platform": d["platform"],
|
||||
"docker": d["docker"],
|
||||
"features": features,
|
||||
"default_dirs": {
|
||||
"vst": d["default_vst_dirs"],
|
||||
"soundfont": d["default_soundfont_dirs"],
|
||||
"preset": d["preset_dir"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def is_desktop() -> bool:
|
||||
return detect()["runtime"] == "desktop"
|
||||
|
||||
|
||||
def is_headless() -> bool:
|
||||
return detect()["runtime"] == "headless"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# python -m app.core.runtime → in capabilities để kiểm tra detect
|
||||
import json
|
||||
print(json.dumps(capabilities(), ensure_ascii=False, indent=2))
|
||||
@@ -408,3 +408,70 @@ class DecentSamplerManager:
|
||||
os.chdir(cwd_before)
|
||||
|
||||
return plugin
|
||||
|
||||
|
||||
# ── Preset bridge (Carla ↔ pedalboard) ─────────────────────────────────────
|
||||
# Carla (chạy ngoài, user tự cài — GPL-2.0+ nên app KHÔNG bundle/nhúng) dùng
|
||||
# để mở native GUI VSTi + xuất file preset (.vstpreset từ nút Save của plugin).
|
||||
# File preset nằm trong thư viện storage/presets (upload qua web UI hoặc picker
|
||||
# local trên Windows) → render_engine tải qua apply_preset_to_plugin() khi render.
|
||||
PRESET_EXTENSIONS = (".vstpreset", ".fxp", ".fxb", ".dspreset")
|
||||
|
||||
|
||||
def preset_library_dir() -> str:
|
||||
from app.config import settings as _st
|
||||
d = os.path.join(_st.STORAGE_DIR, "presets")
|
||||
try:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
def resolve_preset_path(preset_id_or_path: str) -> str:
|
||||
"""preset_id (tên file trong thư viện) hoặc đường dẫn tuyệt đối → path.
|
||||
|
||||
Trả '' nếu không tìm thấy. Chống path traversal: chỉ chấp nhận tên file
|
||||
(không chứa separator) hoặc đường dẫn tuyệt đối tồn tại."""
|
||||
if not preset_id_or_path:
|
||||
return ""
|
||||
p = preset_id_or_path
|
||||
# Đường dẫn tuyệt đối / tương đối tồn tại → dùng thẳng
|
||||
if os.path.isfile(p):
|
||||
return p
|
||||
# id dạng tên file trong thư viện (uuid + ext)
|
||||
if os.path.basename(p) == p:
|
||||
cand = os.path.join(preset_library_dir(), p)
|
||||
if os.path.isfile(cand):
|
||||
return cand
|
||||
# Không có ext → quét theo prefix (uuid.idx → uuid.vstpreset)
|
||||
try:
|
||||
for f in os.listdir(preset_library_dir()):
|
||||
if f.lower().endswith(PRESET_EXTENSIONS) and f.startswith(p + "."):
|
||||
return os.path.join(preset_library_dir(), f)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def apply_preset_to_plugin(plugin, preset_id=None, preset_path=None, preset_data_b64=None) -> bool:
|
||||
"""Gán preset lên plugin pedalboard: bytes nhúng (base64 .vstpreset) ưu
|
||||
tiên, sau đó preset_id (thư viện), sau preset_path (file). Trả True nếu
|
||||
áp dụng được; KHÔNG raise (render engine chỉ log warning)."""
|
||||
if plugin is None:
|
||||
return False
|
||||
try:
|
||||
if preset_data_b64:
|
||||
import base64
|
||||
raw = base64.b64decode(preset_data_b64)
|
||||
if hasattr(plugin, "preset_data"):
|
||||
plugin.preset_data = raw # bytes dạng .vstpreset (VST3)
|
||||
return True
|
||||
p = resolve_preset_path(preset_id or "") or resolve_preset_path(preset_path or "")
|
||||
if p and hasattr(plugin, "load_preset"):
|
||||
plugin.load_preset(p) # .vstpreset (VST3) / .dspreset (DecentSampler)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
+201
-22
@@ -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 }) => {
|
||||
<div className={`flex items-center gap-1 px-2 py-1 cursor-pointer hover:bg-blue-100 ${!synthInst ? 'bg-slate-200' : ''}`} onClick={() => selectSynthInst(null)}>
|
||||
<i className="fa-solid fa-ban text-slate-400"></i> None (mặc định)
|
||||
</div>
|
||||
{window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.features && window.SonicRuntime.capabilities.features.carla_local && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 cursor-pointer hover:bg-teal-100 font-semibold text-teal-700 border-t border-[#e0e0e0]" onClick={() => { setSynthOpen(false); window.SonicAPI.openInCarla().then(r => { if (r && r.success) showToast('Đã mở Carla — chỉnh preset rồi Upload trong app', 'success'); }).catch(err => showToast('Lỗi mở Carla: ' + (err.message || err), 'error')); }}>
|
||||
<i className="fa-solid fa-sliders text-teal-500"></i> 🎛 Carla Bridge (mở Carla.exe)
|
||||
</div>
|
||||
)}
|
||||
{!synthLoading && filteredSynthList && filteredSynthList.map(group => (
|
||||
<div key={group.sf.id || group.sf.name}>
|
||||
<div className="px-2 py-1 bg-[#f0f0f0] font-semibold text-slate-600 border-t border-[#e0e0e0] truncate">{group.sf.display || group.sf.name || group.sf.id}</div>
|
||||
@@ -15520,6 +15626,27 @@ const App = () => {
|
||||
setAiPrompt(newText);
|
||||
};
|
||||
|
||||
// Gán preset VST3 (.vstpreset từ thư 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")
|
||||
)));
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -17,6 +17,8 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const url = `${window.API_BASE_URL}${endpoint}`;
|
||||
const headers = { ...getAuthHeaders(), ...options.headers };
|
||||
// FormData: browser tự đặt Content-Type kèm boundary — không được ép JSON
|
||||
if (options.body instanceof FormData) delete headers['Content-Type'];
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (response.status === 401) {
|
||||
@@ -67,6 +69,24 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
|
||||
getPluginDirs: () => apiRequest('/api/v1/plugins/dirs', { method: 'GET' }),
|
||||
savePluginDirs: (dirs) => apiRequest('/api/v1/plugins/dirs', { method: 'POST', body: JSON.stringify(dirs) }),
|
||||
scanPluginDirs: () => apiRequest('/api/v1/plugins/scan', { method: 'POST' }),
|
||||
// Runtime capabilities — frontend gọi lúc boot để biết môi trường
|
||||
// (desktop Windows / docker headless) và bật/tắt tính năng
|
||||
getCapabilities: () => apiRequest('/api/v1/system/capabilities', { method: 'GET' }),
|
||||
// Định vị Carla.exe (bản portable zip không cài đặt/PATH) — lưu config
|
||||
setCarlaPath: (path) => apiRequest('/api/v1/system/carla-path', { method: 'POST', body: JSON.stringify({ carla_path: path }) }),
|
||||
// Mở native GUI VSTi trong Carla (chỉ khi runtime=desktop + có Carla local)
|
||||
openInCarla: (pluginName, pluginPath) => apiRequest('/api/v1/plugins/open-in-carla', { method: 'POST', body: JSON.stringify({ plugin_name: pluginName, plugin_path: pluginPath }) }),
|
||||
// Quick-render preview VSTi (âm thật = âm export, cùng code path)
|
||||
previewInstrument: (payload) => apiRequest('/api/v1/plugins/preview', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
// Thư viện preset VST3 (.vstpreset) — cầu nối Carla → pedalboard
|
||||
listPresets: () => apiRequest('/api/v1/presets', { method: 'GET' }),
|
||||
uploadPreset: (file, pluginHint) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (pluginHint) fd.append('plugin_hint', pluginHint);
|
||||
return apiRequest('/api/v1/presets/upload', { method: 'POST', body: fd });
|
||||
},
|
||||
deletePreset: (presetId) => apiRequest(`/api/v1/presets/${presetId}`, { method: 'DELETE' }),
|
||||
// Native folder picker (Explorer qua Tauri bridge / PowerShell) —
|
||||
// user yêu cầu dùng window explorer, không nhập tay
|
||||
pickPluginDir: () => apiRequest('/api/v1/plugins/pick-dir', { method: 'POST' }),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// SonicForge Runtime service — phát hiện môi trường chạy (desktop Windows /
|
||||
// docker headless) qua /api/v1/system/capabilities, bật/tắt tính năng theo đó.
|
||||
// - data-runtime trên <html>: "desktop" | "headless"
|
||||
// - data-carla="1": có Carla local (hiện nút "Mở trong Carla")
|
||||
// - Phần tử có thuộc tính data-carla-only sẽ bị ẩn khi không có Carla local.
|
||||
// - Thư viện preset (.vstpreset) cache trong SonicRuntime.presets — dùng cho
|
||||
// dropdown gán preset vào track (Carla → pedalboard bridge).
|
||||
window.SonicRuntime = window.SonicRuntime || { loaded: false, capabilities: null, presets: null };
|
||||
|
||||
(function () {
|
||||
function getHeaders() {
|
||||
const token = localStorage.getItem('sonic_token') || '';
|
||||
return token ? { 'Authorization': 'Bearer ' + token } : {};
|
||||
}
|
||||
|
||||
function load() {
|
||||
return fetch(window.API_BASE_URL + '/api/v1/system/capabilities')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var c = data && data.success ? data : { features: {} };
|
||||
window.SonicRuntime.capabilities = c;
|
||||
window.SonicRuntime.loaded = true;
|
||||
var html = document.documentElement;
|
||||
html.dataset.runtime = c.runtime || 'unknown';
|
||||
html.dataset.platform = c.platform || '';
|
||||
html.dataset.carla = (c.features && c.features.carla_local) ? '1' : '0';
|
||||
if (c.features && c.features.carla_local === false) {
|
||||
document.querySelectorAll('[data-carla-only]').forEach(function (el) {
|
||||
el.style.display = 'none';
|
||||
});
|
||||
}
|
||||
// Cache sẵn danh sách preset (static, ít thay đổi)
|
||||
listPresets().catch(function () {});
|
||||
return c;
|
||||
})
|
||||
.catch(function () { return null; });
|
||||
}
|
||||
|
||||
function listPresets() {
|
||||
if (window.SonicRuntime.presets) return Promise.resolve(window.SonicRuntime.presets);
|
||||
return fetch(window.API_BASE_URL + '/api/v1/presets', { headers: getHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
window.SonicRuntime.presets = (d && d.presets) || [];
|
||||
return window.SonicRuntime.presets;
|
||||
})
|
||||
.catch(function () { return []; });
|
||||
}
|
||||
|
||||
window.SonicRuntime.load = load;
|
||||
window.SonicRuntime.listPresets = listPresets;
|
||||
window.SonicRuntime.refreshPresets = function () {
|
||||
window.SonicRuntime.presets = null;
|
||||
return window.SonicRuntime.listPresets();
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', load);
|
||||
} else {
|
||||
load();
|
||||
}
|
||||
})();
|
||||
@@ -31,6 +31,7 @@
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
||||
<script src="/static/js/services/fluidsynthLoader.js?v=202607271245"></script>
|
||||
<script src="/static/js/services/runtime.js?v=20260809"></script>
|
||||
<script src="/static/js/services/api.js?v=202608091400"></script>
|
||||
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
||||
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
||||
|
||||
Reference in New Issue
Block a user