FIX: Carla sử dụng bridge

This commit is contained in:
2026-08-10 07:57:26 +07:00
parent fbaac1f673
commit aa32272f36
17 changed files with 1372 additions and 34 deletions
+131 -1
View File
@@ -1,10 +1,12 @@
import os, sys, uuid, json, tempfile, subprocess, time as _time
import numpy as np
import soundfile as sf
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional, Any
from app.config import settings
from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH
from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH, apply_preset_to_plugin
from app.core.render_engine import PythonRenderEngine
from app.core.soundfont_inspector import SoundFontInspector
from app.core.soundfont_converter import SoundFontConverter
@@ -446,6 +448,134 @@ class RenderRequest(BaseModel):
output_filename: Optional[str] = "render_output.wav"
class OpenInCarlaRequest(BaseModel):
"""Mở native GUI của VSTi trong Carla (chỉ khả dụng khi runtime=desktop
và Carla được cài trên cùng máy — tự phát hiện qua runtime profile)."""
plugin_name: Optional[str] = None
plugin_path: Optional[str] = None
class PreviewRequest(BaseModel):
"""Quick-render preview: pedalboard render clip ngắn bằng ĐÚNG plugin +
preset (cùng code path với export) → trả wav để browser phát.
Âm preview = âm export (khác Preview Synth WASM hiện tại)."""
instrument_id: str
notes: list = []
bpm: float = 120.0
sample_rate: int = 44100
soundfont_bank: Optional[int] = 0
soundfont_program: Optional[int] = 0
preset_id: Optional[str] = None
preset_path: Optional[str] = None
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng)
@router.post("/open-in-carla")
async def open_in_carla(req: OpenInCarlaRequest, current_user: dict = Depends(get_current_user)):
"""Mở Carla (native GUI host) trên máy hiện tại — Windows desktop mode.
Carla là app ngoài do user tự cài (GPL-2.0+ → không bundle/nhúng). App chỉ
spawn tiến trình; user chỉnh preset trong GUI rồi Save → .vstpreset → upload
vào thư viện preset → gán vào track → render engine tải preset tương ứng."""
enforce_password_changed(current_user)
from app.core.runtime import find_carla
carla = find_carla()
if not carla:
raise HTTPException(
status_code=409,
detail="Không tìm thấy Carla trên máy này. Hãy giải nén bản Carla "
"portable (zip, miễn phí) từ https://github.com/falkTX/Carla/releases, "
"rồi vào Plugin Manager → Carla Bridge → Định vị Carla... để chọn "
"thư mục chứa carla.exe (bản portable không dùng PATH).",
)
plugin_path = req.plugin_path or ""
if not plugin_path and req.plugin_name:
try:
pm = PluginManager()
plugins = pm._scan_plugins()
if req.plugin_name in plugins:
plugin_path = plugins[req.plugin_name]
except Exception:
plugin_path = ""
cmd = [carla]
if plugin_path:
# carla-single: mở thẳng 1 plugin thành app standalone có native GUI
single = os.path.join(
os.path.dirname(carla),
"carla-single" + (".exe" if os.name == "nt" else ""),
)
if os.path.isfile(single):
cmd = [single, plugin_path]
try:
cwd = os.path.dirname(carla) or None
subprocess.Popen(cmd, cwd=cwd, close_fds=os.name != "nt")
return {
"success": True,
"started": True,
"carla_path": carla,
"plugin_path": plugin_path,
"cmd": cmd,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Không mở được Carla: {e}")
@router.post("/preview")
async def preview_instrument(req: PreviewRequest):
"""Quick-render preview VSTi (âm thật, cùng code path với export)."""
if not HAS_PEDALBOARD:
raise HTTPException(status_code=501, detail="pedalboard không khả dụng trên máy này")
if not req.notes:
raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để preview")
try:
pm = PluginManager()
vst = pm.load_vst(req.instrument_id)
if vst is None:
raise HTTPException(status_code=404, detail=f"Không tìm thấy VSTi: {req.instrument_id}")
apply_preset_to_plugin(
vst,
preset_id=req.preset_id,
preset_path=req.preset_path,
preset_data_b64=req.preset_data,
)
from pedalboard import Pedalboard
midi_events = []
for n in req.notes:
midi_events.append({
"note": int(n.get("pitch", 60)),
"start_beat": float(n.get("start_beat", 0)),
"duration_beats": float(n.get("duration_beats", 1)),
"velocity": int(float(n.get("velocity", 0.8)) * 127),
})
midi_messages = PluginManager.midi_events_to_messages(
midi_events, req.bpm, req.sample_rate,
bank=req.soundfont_bank, program=req.soundfont_program,
)
total_needed = 0
beat_sec = 60.0 / max(30.0, req.bpm)
for ev in midi_events:
end_sec = (ev["start_beat"] + ev["duration_beats"]) * beat_sec
if int(end_sec * req.sample_rate) > total_needed:
total_needed = int(end_sec * req.sample_rate)
total_needed = max(total_needed, 1024)
silent = np.zeros((2, total_needed), dtype=np.float32)
board = Pedalboard([vst])
buf = board(silent, sample_rate=req.sample_rate, midi_messages=midi_messages)
fname = f"preview_{uuid.uuid4().hex[:10]}.wav"
out_path = os.path.join(settings.PROCESSED_DIR, fname)
sf.write(out_path, buf.T, req.sample_rate)
return {
"success": True,
"url": f"/static/audio/processed/{fname}",
"path": out_path,
"duration_sec": round(buf.shape[1] / float(req.sample_rate), 3),
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Preview thất bại: {e}")
@router.post("/render")
async def render_project(
req: RenderRequest,
+129
View File
@@ -0,0 +1,129 @@
# SonicForge Preset Library API — thư viện preset VST3 (.vstpreset) nằm trong
# storage/presets (mount qua volume trong docker; thư mục storage trên Windows).
#
# Vai trò: cầu nối Carla → pedalboard. User chỉnh preset trong Carla (native
# GUI) → xuất .vstpreset → upload vào thư viện → gán vào track (preset_id trong
# synth_engine) → render_engine tải qua load_preset → âm render = âm đã chỉnh.
import os
import uuid
import json
import time
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
from fastapi.responses import FileResponse
from typing import Optional
from app.core.vst_engine import preset_library_dir, PRESET_EXTENSIONS
from app.api.v1.auth import get_current_user, enforce_password_changed
router = APIRouter()
def _safe_preset_path(preset_id: str) -> str:
"""Chống path traversal: chỉ cho phép tên file (không chứa separator)."""
if not preset_id or os.path.basename(preset_id) != preset_id:
return ""
d = preset_library_dir()
p = os.path.join(d, preset_id)
if os.path.isfile(p) and os.path.dirname(os.path.abspath(p)) == os.path.abspath(d):
return p
return ""
@router.get("")
async def list_presets():
"""Danh sách preset trong thư viện (public — frontend cần trước login)."""
d = preset_library_dir()
items = []
try:
names = sorted(os.listdir(d))
except Exception:
names = []
for f in names:
low = f.lower()
if not low.endswith(PRESET_EXTENSIONS):
continue
meta = {}
meta_path = os.path.join(d, os.path.splitext(f)[0] + ".meta")
if os.path.isfile(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as mf:
meta = json.load(mf)
except Exception:
meta = {}
try:
size = os.path.getsize(os.path.join(d, f))
except Exception:
size = 0
items.append({
"id": f,
"name": meta.get("original_name", f),
"plugin_hint": meta.get("plugin_hint", ""),
"size_bytes": size,
"created_at": meta.get("created_at", ""),
})
return {"success": True, "presets": items}
@router.post("/upload")
async def upload_preset(
file: UploadFile = File(...),
plugin_hint: Optional[str] = None,
current_user: dict = Depends(get_current_user),
):
"""Upload preset (.vstpreset / .fxp / .fxb / .dspreset) vào thư viện."""
enforce_password_changed(current_user)
filename = (file.filename or "preset.vstpreset").replace("\\", "/").split("/")[-1]
ext = os.path.splitext(filename)[1].lower()
if ext not in PRESET_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Định dạng preset không hỗ trợ: {ext or '(không có đuôi)'} — hỗ trợ: {', '.join(PRESET_EXTENSIONS)}",
)
contents = await file.read()
if not contents:
raise HTTPException(status_code=400, detail="File rỗng")
d = preset_library_dir()
preset_id = uuid.uuid4().hex + ext
dest = os.path.join(d, preset_id)
try:
with open(dest, "wb") as fh:
fh.write(contents)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Không lưu được preset: {e}")
meta = {
"original_name": filename,
"plugin_hint": plugin_hint or "",
"size_bytes": len(contents),
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
try:
with open(os.path.join(d, os.path.splitext(preset_id)[0] + ".meta"), "w", encoding="utf-8") as mf:
json.dump(meta, mf, ensure_ascii=False, indent=2)
except Exception:
pass
return {"success": True, "preset_id": preset_id, **meta}
@router.get("/{preset_id}/download")
async def download_preset(preset_id: str):
path = _safe_preset_path(preset_id)
if not path:
raise HTTPException(status_code=404, detail="Preset không tồn tại")
return FileResponse(path, filename=preset_id, media_type="application/octet-stream")
@router.delete("/{preset_id}")
async def delete_preset(preset_id: str, current_user: dict = Depends(get_current_user)):
enforce_password_changed(current_user)
path = _safe_preset_path(preset_id)
if not path:
raise HTTPException(status_code=404, detail="Preset không tồn tại")
try:
os.remove(path)
mp = os.path.join(preset_library_dir(), os.path.splitext(preset_id)[0] + ".meta")
if os.path.isfile(mp):
os.remove(mp)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Không xóa được preset: {e}")
return {"success": True, "preset_id": preset_id}
+51
View File
@@ -0,0 +1,51 @@
# SonicForge System API — capabilities: frontend gọi 1 lần lúc boot để biết
# môi trường (desktop Windows / docker headless) và bật/tắt tính năng tương ứng.
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from app.core.runtime import capabilities, save_carla_path
from app.api.v1.auth import get_current_user, enforce_password_changed
router = APIRouter()
@router.get("/capabilities")
async def get_capabilities():
"""Khả năng của môi trường hiện tại (public — cần trước khi đăng nhập).
- runtime: "desktop" (server + client cùng 1 máy Windows/macOS) |
"headless" (docker server + browser UI)
- features.carla_local: có Carla trên máy này → hiện nút "Mở trong Carla"
- features.preset_upload: luôn True (upload .vstpreset qua web UI)
- features.preview_mode: "quick_render" (pedalboard render clip ngắn —
âm thật giống export) | "wasm" (Preview Synth trong browser)
"""
return capabilities()
class CarlaPathRequest(BaseModel):
"""Định vị Carla (bản portable zip không cài đặt/PATH). Chấp nhận đường
dẫn tới carla.exe HOẶC thư mục chứa carla.exe — resolve và lưu config."""
carla_path: str
carla_dir: Optional[str] = None # tương thích ngược: tên cũ của carla_path
@router.post("/carla-path")
async def set_carla_path(req: CarlaPathRequest, current_user: dict = Depends(get_current_user)):
"""Lưu vị trí carla.exe do user chọn (Plugin Manager → Định vị Carla...).
Cần thiết vì bản Carla Windows là bộ file zip portable — không có installer
cũng không dùng biến môi trường PATH, nên heuristic không tìm thấy."""
enforce_password_changed(current_user)
target = (req.carla_path or req.carla_dir or "").strip()
if not target:
raise HTTPException(status_code=400, detail="Thiếu đường dẫn Carla")
exe = save_carla_path(target)
if not exe:
raise HTTPException(
status_code=400,
detail="Không tìm thấy carla.exe trong đường dẫn đã chọn. Hãy chọn "
"thư mục chứa carla.exe (bản portable giải nén) hoặc chính file carla.exe.",
)
return {"success": True, "carla_path": exe, **capabilities()}