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