FIX: Carla sử dụng bridge
This commit is contained in:
@@ -23,3 +23,12 @@ STORAGE_DIR=/app/app/storage
|
|||||||
VST_DIR=/home/locpham/daw_assets/vst3
|
VST_DIR=/home/locpham/daw_assets/vst3
|
||||||
SOUNDFONT_DIR=/home/locpham/daw_assets/soundfonts
|
SOUNDFONT_DIR=/home/locpham/daw_assets/soundfonts
|
||||||
PIANOBK_DIR=/home/locpham/daw_assets/pianobook
|
PIANOBK_DIR=/home/locpham/daw_assets/pianobook
|
||||||
|
|
||||||
|
# ── Runtime (tự phát hiện môi trường) ──
|
||||||
|
# auto (mặc định): Windows/macOS → desktop; Linux không DISPLAY hoặc docker → headless.
|
||||||
|
# desktop: server + client cùng 1 máy (bật nút "Mở trong Carla" nếu có Carla local)
|
||||||
|
# headless: server docker + browser UI (soundfont + VSTi mở được từ storage mount;
|
||||||
|
# preset chỉnh trên máy khác → upload .vstpreset qua web UI)
|
||||||
|
SF_RUNTIME=auto
|
||||||
|
# Ép nhận diện Docker (thường tự detect qua /.dockerenv; đặt =1 nếu cần)
|
||||||
|
SF_DOCKER=1
|
||||||
|
|||||||
+131
-1
@@ -1,10 +1,12 @@
|
|||||||
import os, sys, uuid, json, tempfile, subprocess, time as _time
|
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 import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
from app.config import settings
|
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.render_engine import PythonRenderEngine
|
||||||
from app.core.soundfont_inspector import SoundFontInspector
|
from app.core.soundfont_inspector import SoundFontInspector
|
||||||
from app.core.soundfont_converter import SoundFontConverter
|
from app.core.soundfont_converter import SoundFontConverter
|
||||||
@@ -446,6 +448,134 @@ class RenderRequest(BaseModel):
|
|||||||
output_filename: Optional[str] = "render_output.wav"
|
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")
|
@router.post("/render")
|
||||||
async def render_project(
|
async def render_project(
|
||||||
req: RenderRequest,
|
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")
|
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:
|
class Settings:
|
||||||
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_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")
|
PROCESSED_DIR: str = os.path.join(STORAGE_DIR, "processed")
|
||||||
|
|
||||||
# Plugin dirs — người dùng khai báo qua .env / docker-compose (Docker)
|
# 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).
|
# hoặc qua Plugins Manager (Windows/macOS, lưu theo user). Default theo
|
||||||
VST_DIR: str = os.getenv("VST_DIR", "/opt/daw_engine/vst3")
|
# platform (Windows: thư mục VST3 chuẩn; Linux: mount compose).
|
||||||
SOUNDFONT_DIR: str = os.getenv("SOUNDFONT_DIR", "/opt/daw_engine/soundfonts")
|
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()
|
settings = Settings()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.core.vst_engine import (
|
|||||||
DecentSamplerManager,
|
DecentSamplerManager,
|
||||||
HAS_PEDALBOARD,
|
HAS_PEDALBOARD,
|
||||||
HAS_PYFLUIDSYNTH,
|
HAS_PYFLUIDSYNTH,
|
||||||
|
apply_preset_to_plugin,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -223,6 +224,21 @@ class PythonRenderEngine:
|
|||||||
)
|
)
|
||||||
elif vst and HAS_PEDALBOARD:
|
elif vst and HAS_PEDALBOARD:
|
||||||
from pedalboard import 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_messages = PluginManager.midi_events_to_messages(
|
||||||
midi_events, bpm, self.sample_rate,
|
midi_events, bpm, self.sample_rate,
|
||||||
bank=soundfont_bank, program=soundfont_program
|
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)
|
os.chdir(cwd_before)
|
||||||
|
|
||||||
return plugin
|
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.ai_presets import router as ai_presets_router
|
||||||
from app.api.v1.plugins import router as plugins_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.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.auth import seed_admin
|
||||||
from app.core.soundfont_scanner import SoundFontAutoScanner
|
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(ai_presets_router, prefix="/api/v1/ai", tags=["ai"])
|
||||||
app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"])
|
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(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")
|
@app.get("/health")
|
||||||
|
|||||||
+201
-22
@@ -5279,7 +5279,7 @@ const AIConfigModal = ({
|
|||||||
}, loading ? 'Đang lưu...' : 'Lưu Cấu Hình AI'))))));
|
}, 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;
|
if (!isOpen) return null;
|
||||||
const [localData, setLocalData] = React.useState(pluginsData);
|
const [localData, setLocalData] = React.useState(pluginsData);
|
||||||
const [sfUploadStatus, setSfUploadStatus] = React.useState('');
|
const [sfUploadStatus, setSfUploadStatus] = React.useState('');
|
||||||
@@ -5288,6 +5288,12 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
|
|||||||
const [pmScanning, setPmScanning] = React.useState(false);
|
const [pmScanning, setPmScanning] = React.useState(false);
|
||||||
const [pmScanResult, setPmScanResult] = React.useState('');
|
const [pmScanResult, setPmScanResult] = React.useState('');
|
||||||
const [pmScanData, setPmScanData] = React.useState(null); // { vst_found, soundfonts }
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
window.SonicAPI.listPlugins()
|
window.SonicAPI.listPlugins()
|
||||||
@@ -5365,6 +5371,51 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
|
|||||||
const removePluginDir = (dir) => {
|
const removePluginDir = (dir) => {
|
||||||
setPmDirs(prev => prev.filter(d => d !== 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.
|
// Lưu dirs (user override) + scan cả 2 thư mục → refresh list + catalog.
|
||||||
const saveAndScanDirs = async () => {
|
const saveAndScanDirs = async () => {
|
||||||
setPmScanning(true);
|
setPmScanning(true);
|
||||||
@@ -5545,28 +5596,78 @@ const PluginManagerModal = ({ isOpen, onClose, pluginsData }) => {
|
|||||||
) :
|
) :
|
||||||
(localData.soundfonts?.length === 0 ?
|
(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.') :
|
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) =>
|
localData.soundfonts.map((sf, i) => {
|
||||||
React.createElement('div', {
|
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,
|
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', {
|
||||||
React.createElement('div', { className: 'w-8 h-8 rounded bg-amber-900/30 flex items-center justify-center' },
|
className: 'flex items-center justify-between px-4 py-3 cursor-pointer',
|
||||||
React.createElement('i', { 'data-lucide': 'music', className: 'w-4 h-4 text-amber-400' })
|
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: 'flex items-center gap-2' },
|
||||||
React.createElement('div', { className: 'text-xs font-semibold text-slate-200' }, sf.display || sf.name || sf.id),
|
React.createElement('span', { className: 'text-[10px] text-zinc-500' }, expanded ? '▾' : '▸'),
|
||||||
React.createElement('div', { className: 'text-[10px] text-zinc-500' }, sf.file || sf.name)
|
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' },
|
expanded && React.createElement('div', { className: 'border-t border-[#333] px-3 py-1 max-h-40 overflow-y-auto' },
|
||||||
React.createElement('button', {
|
loading ?
|
||||||
onClick: () => setSfToDelete(sf),
|
React.createElement('p', { className: 'text-[10px] text-zinc-500 italic py-1' }, 'Đang đọc instruments...') :
|
||||||
className: 'text-[10px] text-zinc-500 hover:text-red-400 opacity-0 group-hover:opacity-100 transition px-2 py-1'
|
insts.length === 0 ?
|
||||||
}, 'Delete')
|
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)
|
// Plugin directories section (folder picker + save + scan)
|
||||||
React.createElement('div', {
|
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)}>
|
<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)
|
<i className="fa-solid fa-ban text-slate-400"></i> None (mặc định)
|
||||||
</div>
|
</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 => (
|
{!synthLoading && filteredSynthList && filteredSynthList.map(group => (
|
||||||
<div key={group.sf.id || group.sf.name}>
|
<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>
|
<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);
|
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 setTrackInstrumentWithUndo = (trackId, instrumentId, displayName, bankNumber, programNumber) => {
|
||||||
const track = activeTracks.find(t => t.id === trackId);
|
const track = activeTracks.find(t => t.id === trackId);
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
@@ -29802,7 +29929,16 @@ STRICT CONSTRAINTS:
|
|||||||
}), /*#__PURE__*/React.createElement(PluginManagerModal, {
|
}), /*#__PURE__*/React.createElement(PluginManagerModal, {
|
||||||
isOpen: pluginManagerModalOpen,
|
isOpen: pluginManagerModalOpen,
|
||||||
onClose: () => setPluginManagerModalOpen(false),
|
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, {
|
}), /*#__PURE__*/React.createElement(AIPresetModal, {
|
||||||
isOpen: aiPresetModalOpen,
|
isOpen: aiPresetModalOpen,
|
||||||
onClose: () => { setAiPresetModalOpen(false); setAiPresetVersion(v => v + 1); }
|
onClose: () => { setAiPresetModalOpen(false); setAiPresetVersion(v => v + 1); }
|
||||||
@@ -29943,6 +30079,14 @@ STRICT CONSTRAINTS:
|
|||||||
onClick: () => setTrackInstrumentWithUndo(instrumentDropdownTrackId, null),
|
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"
|
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)"),
|
}, "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.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", {
|
filteredInstruments.soundfonts.map((sf, i) => /*#__PURE__*/React.createElement("button", {
|
||||||
key: "sfd_" + i,
|
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"
|
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"))),
|
}, /*#__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.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,
|
key: "vstd_" + i,
|
||||||
onClick: () => { setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null); setTrackInstrumentWithUndo(instrumentDropdownTrackId, v.id, v.name || v.id); },
|
className: "flex items-stretch"
|
||||||
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"))),
|
/*#__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")
|
(!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 = {}) {
|
async function apiRequest(endpoint, options = {}) {
|
||||||
const url = `${window.API_BASE_URL}${endpoint}`;
|
const url = `${window.API_BASE_URL}${endpoint}`;
|
||||||
const headers = { ...getAuthHeaders(), ...options.headers };
|
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 });
|
const response = await fetch(url, { ...options, headers });
|
||||||
|
|
||||||
if (response.status === 401) {
|
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' }),
|
getPluginDirs: () => apiRequest('/api/v1/plugins/dirs', { method: 'GET' }),
|
||||||
savePluginDirs: (dirs) => apiRequest('/api/v1/plugins/dirs', { method: 'POST', body: JSON.stringify(dirs) }),
|
savePluginDirs: (dirs) => apiRequest('/api/v1/plugins/dirs', { method: 'POST', body: JSON.stringify(dirs) }),
|
||||||
scanPluginDirs: () => apiRequest('/api/v1/plugins/scan', { method: 'POST' }),
|
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) —
|
// Native folder picker (Explorer qua Tauri bridge / PowerShell) —
|
||||||
// user yêu cầu dùng window explorer, không nhập tay
|
// user yêu cầu dùng window explorer, không nhập tay
|
||||||
pickPluginDir: () => apiRequest('/api/v1/plugins/pick-dir', { method: 'POST' }),
|
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@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="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/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/api.js?v=202608091400"></script>
|
||||||
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
<script src="/static/js/services/audioEngine.js?v=202607271016"></script>
|
||||||
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
<script src="/static/js/services/storage.js?v=202608038200"></script>
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-8000}:8000"
|
- "${WEB_PORT:-8000}:8000"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
# Ép nhận diện headless (server docker + browser UI) — soundfont + VSTi
|
||||||
|
# mở được lấy từ storage mount bên dưới; preset VST3 (.vstpreset) upload
|
||||||
|
# qua web UI vào storage/presets (nằm trong volume sf_db).
|
||||||
|
- SF_DOCKER=1
|
||||||
volumes:
|
volumes:
|
||||||
- sf_uploads:/app/app/storage/uploads
|
- sf_uploads:/app/app/storage/uploads
|
||||||
- sf_processed:/app/app/storage/processed
|
- sf_processed:/app/app/storage/processed
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# CARLA BRIDGE — NATIVE GUI VSTi (Windows) + DUAL-MODE RUNTIME (Desktop / Docker Headless)
|
||||||
|
|
||||||
|
> Tài liệu kỹ thuật & vận hành: tích hợp Carla làm host native GUI cho VSTi trên
|
||||||
|
> Windows, tự phát hiện môi trường (desktop / headless), thư viện preset
|
||||||
|
> (.vstpreset) làm cầu nối Carla → pedalboard, và Plugin Manager quản lý
|
||||||
|
> soundfont + instrument.
|
||||||
|
>
|
||||||
|
> Phạm vi code: `app/core/runtime.py`, `app/api/v1/system.py`,
|
||||||
|
> `app/api/v1/presets.py`, `app/api/v1/plugins.py`, `app/core/vst_engine.py`,
|
||||||
|
> `app/core/render_engine.py`, `app/static/js/services/runtime.js`, `app.jsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Bối cảnh & quyết định kiến trúc
|
||||||
|
|
||||||
|
| Vấn đề | Quyết định |
|
||||||
|
|---|---|
|
||||||
|
| `pedalboard` **cố tình headless** — không mở được native GUI VSTi | Không thay thế pedalboard; dùng **Carla làm host GUI ngoài** (preview + chỉnh preset), pedalboard giữ nguyên làm **render engine** (offline, cùng code path cho preview & export) |
|
||||||
|
| Browser không chạy được binary `.vst3` (Windows/Linux) | Preview realtime thật chỉ có trong **cửa sổ Carla**; trong web UI dùng **quick-render preview** (pedalboard render clip ngắn → wav — **âm thật = âm export**) |
|
||||||
|
| App chạy 2 môi trường: **Windows desktop** (server+client 1 máy) và **Docker headless** (server + browser UI) | **Runtime profile tự phát hiện** → bật/tắt tính năng theo môi trường (nút Carla Bridge chỉ hiện trên desktop có Carla) |
|
||||||
|
| Carla Windows là **bộ zip portable** — không installer, không PATH | **User tự định vị** thư mục chứa `carla.exe` (Plugin Manager → Định vị Carla...) + tìm kiếm dự phòng (registry, Program Files, quét nông Downloads/Desktop) |
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────── Windows Desktop (1 máy) ──────────────────────────┐
|
||||||
|
│ Browser/Tauri UI ──► FastAPI (localhost:8000) ──► pedalboard (render VST3) │
|
||||||
|
│ │ │ ▲ │
|
||||||
|
│ ▼ ▼ │ load_preset │
|
||||||
|
│ Nút "Carla Bridge" ──► spawn carla.exe (native GUI) │ │
|
||||||
|
│ │ chọn VSTi, chỉnh âm, Save .vstpreset │
|
||||||
|
│ └──────────────► Upload preset ──► storage/presets ─┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌────────────────────────── Docker Headless (server) ─────────────────────────┐
|
||||||
|
│ Browser UI ──► FastAPI (container :8000) ──► pedalboard (VSTi Linux) │
|
||||||
|
│ Soundfont + VSTi mở được: mount volumes (docker-compose.prod.yml) │
|
||||||
|
│ Preset .vstpreset: user chỉnh ở máy desktop → upload qua web UI │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Runtime tự phát hiện môi trường (`app/core/runtime.py`)
|
||||||
|
|
||||||
|
### 2.1 Nguyên tắc
|
||||||
|
|
||||||
|
- **Heuristic + override**: `SF_RUNTIME=auto|desktop|headless` **thắng tuyệt đối**
|
||||||
|
(WSL / docker-in-docker / remote desktop có thể làm heuristic sai).
|
||||||
|
- `SF_DOCKER=1` hoặc tồn tại `/.dockerenv` → nhận diện container.
|
||||||
|
- Linux: không có `DISPLAY` hoặc trong docker → `headless`; ngược lại `desktop`.
|
||||||
|
- Windows/macOS → luôn `desktop`.
|
||||||
|
|
||||||
|
### 2.2 Capabilities API (frontend gọi 1 lần lúc boot)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/system/capabilities (public — cần trước khi đăng nhập)
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"runtime": "desktop", // "desktop" | "headless"
|
||||||
|
"platform": "windows", // "windows" | "linux" | "darwin"
|
||||||
|
"docker": false,
|
||||||
|
"features": {
|
||||||
|
"carla_local": true, // có Carla trên máy → hiện nút "Carla Bridge"
|
||||||
|
"carla_path": "D:/Tools/Carla/carla.exe",
|
||||||
|
"preset_upload": true, // luôn true (upload .vstpreset qua web UI)
|
||||||
|
"vst_render": true, // pedalboard khả dụng
|
||||||
|
"tauri_bridge": false,
|
||||||
|
"preview_mode": "quick_render" // "quick_render" | "wasm"
|
||||||
|
},
|
||||||
|
"default_dirs": { "vst": [...], "soundfont": [...], "preset": "..." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Bảng hành vi theo môi trường
|
||||||
|
|
||||||
|
| Tính năng | Windows desktop | Docker headless |
|
||||||
|
|---|---|---|
|
||||||
|
| Render VSTi | pedalboard `VST3Plugin` + `load_preset` | giống hệt (VSTi **bản Linux**) |
|
||||||
|
| Scan VST/SF | thư mục chuẩn Windows + `plugin_dirs.json` | mount volumes + `plugin_dirs.json` |
|
||||||
|
| Preview VSTi | quick-render (âm thật); realtime trong Carla | quick-render |
|
||||||
|
| Nút "Carla Bridge" (nút Synth) | **Hiện** → spawn `carla.exe` | **Ẩn** (không có GUI local) |
|
||||||
|
| Preset | picker local / upload → thư viện | upload → thư viện |
|
||||||
|
| SoundFont preview | FluidSynth WASM (browser) | giống hệt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Cài đặt Carla trên Windows (bản portable zip)
|
||||||
|
|
||||||
|
Carla phát hành dạng **zip** (`Carla-2.5.x-win64.zip`) — **không có installer,
|
||||||
|
không ghi PATH**. Không bundle Carla vào installer của app (license GPL-2.0+,
|
||||||
|
xem §8).
|
||||||
|
|
||||||
|
1. Tải: <https://github.com/falkTX/Carla/releases> (bản `win64`).
|
||||||
|
2. Giải nén ra bất kỳ đâu (VD `D:\Tools\Carla\`, chứa `carla.exe`).
|
||||||
|
3. Mở app → **Plugin Manager** → section **"Carla Bridge (VSTi native GUI)"** →
|
||||||
|
nút **"Định vị Carla..."** → chọn thư mục chứa `carla.exe` (hoặc chính file
|
||||||
|
`carla.exe`).
|
||||||
|
4. App lưu vào `storage/carla_path.json` → cache detect bị xóa → nút
|
||||||
|
**"Carla Bridge"** hiện trong dropdown nút Synth.
|
||||||
|
|
||||||
|
### 3.1 Thứ tự phát hiện `carla_local`
|
||||||
|
|
||||||
|
| Ưu tiên | Nguồn |
|
||||||
|
|---|---|
|
||||||
|
| 1 | **Config user** (`storage/carla_path.json`) — kênh chính cho bản portable |
|
||||||
|
| 2 | PATH (`shutil.which`) |
|
||||||
|
| 3 | Registry (`HKLM/HKCU\SOFTWARE\Carla\InstallPath`) — nếu cài qua installer |
|
||||||
|
| 4 | Thư mục chuẩn (`Program Files\Carla`, `%LOCALAPPDATA%\Programs\Carla`) |
|
||||||
|
| 5 | **Quét nông** Downloads/Desktop/Documents (độ sâu ≤ 3, bỏ qua `node_modules`, `AppData`, `Windows`...) — không bao giờ quét toàn ổ đĩa |
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/system/carla-path (auth) — lưu vị trí Carla
|
||||||
|
body: { "carla_path": "D:/Tools/Carla" } (thư mục HOẶC file exe)
|
||||||
|
→ trả { success, carla_path, ...capabilities }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Luồng sử dụng end-to-end (Windows)
|
||||||
|
|
||||||
|
1. **Nút Synth** (track strip / btnSynth) → chọn **"🎛 Carla Bridge (mở Carla.exe)"**
|
||||||
|
→ app spawn `carla.exe` (ưu tiên `carla-single <plugin>` nếu chọn kèm plugin).
|
||||||
|
2. Trong Carla: **Add Plugin** → chọn VSTi (Kontakt, Nexus, Vital...) → native GUI
|
||||||
|
hiện ra → chỉnh âm, chọn bank/preset của plugin.
|
||||||
|
3. **Save preset** bằng nút của CHÍNH plugin (không dùng project save của Carla)
|
||||||
|
→ file `.vstpreset`.
|
||||||
|
4. Trong app: dropdown Synth → **"⬆ Upload preset (từ Carla...)"** → chọn file
|
||||||
|
`.vstpreset` → nằm trong **thư viện preset** → chọn preset trong danh sách
|
||||||
|
**"VST Presets"** → gán vào track (`synth_engine.preset_id`).
|
||||||
|
5. **Render**: `render_engine.py` nạp plugin qua `load_vst()` rồi
|
||||||
|
`apply_preset_to_plugin()` → **âm render = âm đã chỉnh trong Carla**
|
||||||
|
(điều kiện: cùng sample rate — xem §7).
|
||||||
|
|
||||||
|
Lưu ý: danh sách VSTi trong dropdown vẫn cần thiết — dùng để **map tên →
|
||||||
|
đường dẫn khi pedalboard nạp plugin lúc render** (không phải để mở GUI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Thư viện preset — cầu nối Carla ↔ pedalboard
|
||||||
|
|
||||||
|
- Thư mục: `{STORAGE_DIR}/presets` (Windows: `%APPDATA%\SonicForgeDAW\storage\presets`;
|
||||||
|
Docker: nằm trong volume `sf_db` → `/app/app/storage/presets`).
|
||||||
|
- Định dạng hỗ trợ: `.vstpreset` (VST3 — chuẩn), `.fxp`/`.fxb` (VST2, chỉ
|
||||||
|
preview trong Carla), `.dspreset` (DecentSampler/Pianobook).
|
||||||
|
- `synth_engine` của track có thể chứa 1 trong 3 dạng (ưu tiên giảm dần):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "vst3", "plugin_id": "Kontakt 7",
|
||||||
|
"preset_data": "<base64 bytes .vstpreset nhúng — project tự chứa state, portable>",
|
||||||
|
"preset_id": "a1b2c3....vstpreset", // thư viện storage/presets
|
||||||
|
"preset_path": "D:/presets/Piano.vstpreset" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/presets (public) — danh sách
|
||||||
|
POST /api/v1/presets/upload (auth) — multipart file + plugin_hint
|
||||||
|
GET /api/v1/presets/{id}/download
|
||||||
|
DELETE /api/v1/presets/{id} (auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick-render preview (âm thật = âm export)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/plugins/preview
|
||||||
|
body: { instrument_id, notes:[{pitch,start_beat,duration_beats,velocity}],
|
||||||
|
bpm, sample_rate, preset_id?, preset_path?, preset_data? }
|
||||||
|
→ { success, url: "/static/audio/processed/preview_xxx.wav", duration_sec }
|
||||||
|
```
|
||||||
|
|
||||||
|
Cùng code path với export (pedalboard + `load_preset`) → **preview nghe đúng
|
||||||
|
plugin/preset** (khác "Preview Synth WASM" cũ — âm giả).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Plugin Manager — SoundFont: Add Directory → Scan → Instrument → Synth
|
||||||
|
|
||||||
|
1. **Add Directory** (folder picker native / in-app browser) → thư mục vào
|
||||||
|
danh sách (lưu `plugin_dirs.json`).
|
||||||
|
2. **Scan** → quét soundfont trong các thư mục (catalog qua
|
||||||
|
`SoundFontAutoScanner`) + liệt kê VST.
|
||||||
|
3. Bấm vào **một soundfont** (▸) → expand danh sách **instrument bên trong**
|
||||||
|
(Bank/Program/Tên) qua `GET /api/v1/plugins/soundfont-instruments/{sf_id}`.
|
||||||
|
4. Nút **"Chèn vào Synth"** → gán instrument (bank/program) vào **track đang
|
||||||
|
chọn** (`setTrackInstrumentWithProgram`) và đóng Plugin Manager.
|
||||||
|
|
||||||
|
> SF3: instrument đọc sau khi chuyển đổi SF3→SF2 (endpoint download tự chuyển
|
||||||
|
> đổi khi cần). Nếu thiếu libfluidsynth, danh sách trả `[]` (graceful).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Lưu ý kỹ thuật & hạn chế (đã xác minh)
|
||||||
|
|
||||||
|
1. **Sample rate**: Carla chạy theo audio device (thường 48 kHz), pedalboard
|
||||||
|
render mặc định 44.1 kHz → plugin phụ thuộc SR (delay/chorus/oversampling)
|
||||||
|
nghe khác. **Render đúng SR của thiết bị Carla** để preview = export.
|
||||||
|
2. **VST2**: pedalboard 0.10+ **đã gỡ hỗ trợ VST2** (chỉ còn VST3) → chỉ preset
|
||||||
|
VST3 (`.vstpreset`) round-trip được. Plugin VST2 cũ: preview được trong
|
||||||
|
Carla nhưng **không render** được qua pedalboard.
|
||||||
|
3. **VSTi trên Docker**: chỉ chạy được plugin có bản **Linux** (`.vst3`/`.so`);
|
||||||
|
plugin Windows-only (`.dll`) không chạy trên server Linux (không khuyến nghị
|
||||||
|
Wine bridge trong container).
|
||||||
|
4. **License Carla GPL-2.0+**: app **không bundle/nhúng** Carla — chỉ spawn
|
||||||
|
tiến trình ngoài + trao đổi file preset (không link code) → không dính
|
||||||
|
copyleft. User tự tải zip.
|
||||||
|
5. **Preview WASM ≠ âm thật**: Preview Synth trong browser không phải plugin
|
||||||
|
thật — dùng quick-render (`/plugins/preview`) nếu cần nghe đúng âm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Checklist QA
|
||||||
|
|
||||||
|
- [ ] Windows: cài Carla zip → Định vị Carla → nút "Carla Bridge" hiện ở nút Synth
|
||||||
|
- [ ] Bấm Carla Bridge → `carla.exe` chạy, mở được VSTi + native GUI
|
||||||
|
- [ ] Save `.vstpreset` từ GUI plugin → Upload trong app → gán vào track → render ra âm đúng preset
|
||||||
|
- [ ] Render cùng SR với Carla → preview (Carla) nghe = âm export
|
||||||
|
- [ ] Plugin Manager: Add Directory → Scan → expand soundfont → "Chèn vào Synth" gán đúng bank/program vào track đang chọn
|
||||||
|
- [ ] Docker (`SF_DOCKER=1`): capabilities `runtime=headless`, không hiện Carla Bridge, preset upload hoạt động, render VSTi Linux + soundfont từ mount OK
|
||||||
|
- [ ] `SF_RUNTIME=desktop` trên Linux có DISPLAY → chạy như desktop
|
||||||
|
- [ ] pytest: `86 passed, 7 skipped`
|
||||||
@@ -3031,3 +3031,8 @@
|
|||||||
- **FIX (app.jsx effect follow):** `const playing = isPlaying || !!st.isPlaying` — follow khi piano roll play LẪN main play; deps thêm `st.isPlaying`.
|
- **FIX (app.jsx effect follow):** `const playing = isPlaying || !!st.isPlaying` — follow khi piano roll play LẪN main play; deps thêm `st.isPlaying`.
|
||||||
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070950), `wiki.md`. Rebuild precompiled (build PASS).
|
- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/templates/index.html` (?v=202608070950), `wiki.md`. Rebuild precompiled (build PASS).
|
||||||
- **Ghi chú/Test:** hard refresh → PIANO ROLL → play (tab) → playhead ở giữa + notes trôi trái; stop → về đầu.
|
- **Ghi chú/Test:** hard refresh → PIANO ROLL → play (tab) → playhead ở giữa + notes trôi trái; stop → về đầu.
|
||||||
|
|
||||||
|
### [2026-08-09] Task: Carla Bridge (native GUI VSTi) + dual-mode runtime + preset library
|
||||||
|
- **Tóm tắt thay đổi:** (1) Runtime tự phát hiện môi trường `app/core/runtime.py` (desktop Windows / docker headless; override `SF_RUNTIME`/`SF_DOCKER`) + `GET /api/v1/system/capabilities` (public) — frontend bật/tắt tính năng theo môi trường. (2) Carla Bridge: mục "🎛 Carla Bridge (mở Carla.exe)" trong dropdown nút Synth (chỉ hiện khi desktop + có Carla local) → spawn `carla.exe` qua `POST /api/v1/plugins/open-in-carla`; vì bản Windows là zip portable (không installer, không PATH) → Plugin Manager thêm section "Carla Bridge" + nút "Định vị Carla..." (`POST /api/v1/system/carla-path`, lưu `storage/carla_path.json` ưu tiên cao nhất; kèm registry + quét nông Downloads/Desktop/Documents giới hạn độ sâu). (3) Thư viện preset `app/api/v1/presets.py` (storage/presets: list/upload/download/delete, chống path traversal) + `apply_preset_to_plugin()` trong `vst_engine.py` (preset_data base64 → preset_id → preset_path) → render_engine nạp preset trước khi render VST3 → âm render = âm đã chỉnh trong Carla. (4) `POST /api/v1/plugins/preview` — quick-render preview (cùng code path pedalboard với export → âm thật). (5) Plugin Manager: bấm soundfont expand → liệt kê instrument (bank/program/name) + nút "Chèn vào Synth" gán vào track đang chọn. (6) `config.py` default VST_DIR/SOUNDFONT_DIR theo platform + `PRESET_DIR`; docker-compose `SF_DOCKER=1`; service `runtime.js` (capabilities lúc boot, cache preset) + api.js methods mới (getCapabilities/setCarlaPath/openInCarla/previewInstrument/listPresets/uploadPreset/deletePreset).
|
||||||
|
- **Các file ảnh hưởng:** `app/core/runtime.py` (mới), `app/api/v1/system.py` (mới), `app/api/v1/presets.py` (mới), `app/api/v1/plugins.py`, `app/core/vst_engine.py`, `app/core/render_engine.py`, `app/config.py`, `app/main.py`, `app/static/js/services/runtime.js` (mới), `app/static/js/services/api.js`, `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` (rebuild), `app/templates/index.html`, `.env.example`, `docker-compose.prod.yml`, `md/52_CARLA_BRIDGE.md` (mới), `wiki.md`
|
||||||
|
- **Ghi chú/Test (nếu có):** `pytest`: 86 passed, 7 skipped. Test API: capabilities 200; preset CRUD + chặn path traversal; carla-path (thư mục/exe → resolve exe, invalid → 400); open-in-carla → 409 kèm hướng dẫn khi chưa có Carla; preview → 501 khi thiếu pedalboard. Rebuild bundle BUILD OK. Lưu ý: pedalboard 0.10+ đã bỏ VST2 (chỉ preset VST3 `.vstpreset` round-trip); render cùng sample rate với Carla để preview = export; Carla GPL-2.0+ → không bundle/nhúng, chỉ spawn tiến trình ngoài.
|
||||||
|
|||||||
Reference in New Issue
Block a user