import os, sys, uuid, json, tempfile, subprocess, time as _time, threading
import numpy as np
import soundfile as sf
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header
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, 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
from app.core.soundfont_scanner import SoundFontAutoScanner
from app.api.v1.auth import get_current_user, enforce_password_changed
router = APIRouter()
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
os.makedirs(UPLOAD_SF_DIR, exist_ok=True)
SYSTEM_SF_DIR = settings.SOUNDFONT_DIR
SYSTEM_VST_DIR = settings.VST_DIR
# User dirs (Windows/macOS — người dùng chọn qua folder picker trong
# Plugins Manager). File global (không per-user): desktop app 1 user.
PLUGIN_DIRS_FILE = os.path.join(settings.STORAGE_DIR, "plugin_dirs.json")
def _load_plugin_dirs() -> dict:
if os.path.exists(PLUGIN_DIRS_FILE):
try:
with open(PLUGIN_DIRS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save_plugin_dirs(dirs: dict):
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
with open(PLUGIN_DIRS_FILE, "w", encoding="utf-8") as f:
json.dump(dirs, f, indent=2)
def _effective_dirs() -> dict:
"""Env/.env (Docker) là base; user dirs (file) override nếu khai báo.
plugin_dirs: list thư mục user thêm trong Plugins Manager (mỗi thư mục
có thể chứa cả VST lẫn SoundFont — scan tự phân loại). Nếu user chưa
khai báo → fallback env VST_DIR + SOUNDFONT_DIR.
"""
user = _load_plugin_dirs()
plugin_dirs = [d for d in (user.get("plugin_dirs") or []) if d]
vst_dir = settings.VST_DIR
soundfont_dir = settings.SOUNDFONT_DIR
# Backward compat: file cũ lưu vst_dir/soundfont_dir riêng → gộp vào list.
if not plugin_dirs:
if user.get("vst_dir"):
plugin_dirs.append(user["vst_dir"])
if user.get("soundfont_dir"):
plugin_dirs.append(user["soundfont_dir"])
if not plugin_dirs:
plugin_dirs = [vst_dir, soundfont_dir]
return {
"plugin_dirs": plugin_dirs,
"vst_dir": vst_dir,
"soundfont_dir": soundfont_dir,
"plugin_dirs_user_set": bool(user.get("plugin_dirs")),
}
class DirsRequest(BaseModel):
vst_dir: Optional[str] = None
soundfont_dir: Optional[str] = None
plugin_dirs: Optional[list] = None
@router.get("/dirs")
async def get_plugin_dirs():
return {"success": True, **(_effective_dirs())}
@router.post("/dirs")
async def save_plugin_dirs(req: DirsRequest, current_user: dict = Depends(get_current_user)):
enforce_password_changed(current_user)
user = _load_plugin_dirs()
if req.plugin_dirs is not None:
user["plugin_dirs"] = [d.strip() for d in req.plugin_dirs if d and d.strip()]
# Xóa field cũ (đã gộp vào plugin_dirs) tránh nhầm lẫn
user.pop("vst_dir", None)
user.pop("soundfont_dir", None)
else:
if req.vst_dir is not None:
user["vst_dir"] = req.vst_dir.strip()
if req.soundfont_dir is not None:
user["soundfont_dir"] = req.soundfont_dir.strip()
_save_plugin_dirs(user)
return {"success": True, **(_effective_dirs())}
@router.post("/scan")
async def scan_plugin_dirs(background_tasks: BackgroundTasks = None,
current_user: dict = Depends(get_current_user)):
"""Scan các dir hiệu lực (env + user override): cập nhật catalog
soundfont (inspector/scanner) + liệt kê VST. Trả về danh sách riêng rẽ
VST (vst_found) + SoundFont (soundfonts) theo từng thư mục user khai báo."""
enforce_password_changed(current_user)
dirs = _effective_dirs()
plugin_dirs = dirs["plugin_dirs"]
# SoundFont: quét + inspect vào catalog (scan_once dùng dir hiệu lực)
scanner = SoundFontAutoScanner(system_sf_dirs=plugin_dirs, upload_sf_dir=UPLOAD_SF_DIR)
if background_tasks:
background_tasks.add_task(scanner.scan_once)
else:
scanner.scan_once()
catalog = scanner.get_catalog()
# VST + SoundFont: walk từng thư mục, phân loại riêng rẽ theo extension
vst_found = []
sf_found = []
for d in plugin_dirs:
if not os.path.isdir(d):
continue
for root, dirs, files in os.walk(d):
# Windows: VST3 là FOLDER tên X.vst3 (chứa X.vst3.dll bên trong)
for sub in list(dirs):
if sub.lower().endswith(".vst3"):
vst_found.append({"name": os.path.splitext(sub)[0],
"path": os.path.join(root, sub),
"dir": d,
"type": "VST3"})
for f in files:
low = f.lower()
if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
vst_found.append({"name": os.path.splitext(f)[0],
"path": os.path.join(root, f),
"dir": d,
"type": "VST3" if low.endswith(".vst3") else "VST2"})
elif low.endswith(".sf2") or low.endswith(".sf3"):
sf_found.append({"name": os.path.splitext(f)[0],
"path": os.path.join(root, f),
"dir": d})
return {
"success": True,
"plugin_dirs": plugin_dirs,
"vst_found": vst_found,
"vst_count": len(vst_found),
"soundfonts": sf_found,
"soundfont_count": len(sf_found),
}
_inspector = None
_scanner = None
def get_inspector():
global _inspector
if _inspector is None:
d = _effective_dirs()
_inspector = SoundFontInspector(d["plugin_dirs"][0] if d["plugin_dirs"] else settings.SOUNDFONT_DIR,
upload_sf_dir=UPLOAD_SF_DIR)
return _inspector
def get_scanner():
global _scanner
if _scanner is None:
d = _effective_dirs()
_scanner = SoundFontAutoScanner(system_sf_dirs=d["plugin_dirs"], upload_sf_dir=UPLOAD_SF_DIR)
_scanner.scan_once()
return _scanner
@router.get("/available")
async def list_plugins(current_user: dict = Depends(get_current_user)):
d = _effective_dirs()
pm = PluginManager(vst_dir=d["vst_dir"], sf_dir=d["soundfont_dir"], upload_sf_dir=UPLOAD_SF_DIR, extra_vst_dirs=d["plugin_dirs"])
avail = pm.list_available()
# Gộp VST từ plugin_dirs user đã scan — list_available() CHỈ quét vst_dir
# env (mặc định /opt/daw_engine/vst3) → Synth dropdown không thấy VSTi mà
# Plugin Manager đã scan trong thư mục user chọn (bug: nút Synth rỗng).
extra = _scan_vst_in_dirs(d["plugin_dirs"])
by_id = {v["id"]: v for v in avail["vst_instruments"]}
for v in extra:
by_id.setdefault(v["id"], v)
avail["vst_instruments"] = list(by_id.values())
# Tương tự cho SOUNDFONT: gộp .sf2/.sf3 từ plugin_dirs user — nếu không,
# nút Synth không liệt kê instrument của soundfont trong thư mục user thêm.
sf_by_id = {s["id"]: s for s in avail["soundfonts"]}
for s in _scan_soundfonts_in_dirs(d["plugin_dirs"]):
sf_by_id.setdefault(s["id"], s)
avail["soundfonts"] = list(sf_by_id.values())
return avail
def _scan_soundfonts_in_dirs(dirs: list) -> list:
"""Walk các thư mục (plugin_dirs user) → danh sách soundfont .sf2/.sf3
(id = tên file; name từ .meta nếu có — cùng format PluginManager)."""
found = {}
for d in dirs:
if not d or not os.path.isdir(d):
continue
for root, dirs2, files in os.walk(d):
for f in files:
low = f.lower()
if not (low.endswith(".sf2") or low.endswith(".sf3")):
continue
base_id = os.path.splitext(f)[0]
if base_id in found:
continue
meta = {}
mp = os.path.join(root, os.path.splitext(f)[0] + ".meta")
if os.path.isfile(mp):
try:
with open(mp, "r", encoding="utf-8") as mf:
meta = json.load(mf)
except Exception:
meta = {}
name = meta.get("original_name", f) if meta else f
found[base_id] = {
"id": base_id,
"name": name,
"file": f,
"display": os.path.splitext(name)[0][:40],
"source": "user",
}
# Không walk sâu thêm (soundfont thường đặt ngay trong thư mục khai báo)
dirs2[:] = []
return list(found.values())
def _scan_vst_in_dirs(dirs: list) -> list:
"""Walk các thư mục (plugin_dirs user) → danh sách VST giống /scan:
file .vst3/.dll/.so + FOLDER tên X.vst3 (Windows VST3 = folder chứa
X.vst3.dll bên trong)."""
found = {}
for d in dirs:
if not d or not os.path.isdir(d):
continue
for root, dirs, files in os.walk(d):
# Windows: VST3 là FOLDER tên X.vst3
for sub in list(dirs):
if sub.lower().endswith(".vst3"):
name = os.path.splitext(sub)[0]
if name not in found:
found[name] = {
"id": name, "name": name, "type": "VST3",
"path": os.path.join(root, sub),
}
for f in files:
low = f.lower()
if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
name = os.path.splitext(f)[0]
if name not in found:
found[name] = {
"id": name, "name": name,
"type": "VST3" if low.endswith(".vst3") else "VST2",
"path": os.path.join(root, f),
}
return list(found.values())
# ── Native folder picker (user yêu cầu: dùng Windows Explorer, không phải
# nhập tay) ─────────────────────────────────────────────────────────────
PICK_DIR_TIMEOUT = 120 # user có thể mở dialog lâu
def _pick_dir_via_tauri_bridge() -> Optional[str]:
"""Tauri shell (Rust watcher trong lib.rs) mở NATIVE dialog (IFileDialog /
Explorer) qua file IPC: engine ghi pick_dir.request → Rust mở dialog →
ghi pick_dir.response. Trả None nếu bridge không tồn tại (chạy standalone)."""
root = os.environ.get("APPDATA") or os.path.expanduser("~")
ipc = os.path.join(root, "SonicForgeDAW", "ipc")
if not os.path.isdir(ipc):
return None
# Marker do Rust viết lúc setup — bridge chỉ có trong app Tauri desktop
if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")):
return None
req = os.path.join(ipc, "pick_dir.request")
resp = os.path.join(ipc, "pick_dir.response")
try:
for f in (req, resp):
if os.path.exists(f):
os.remove(f)
with open(req, "w", encoding="utf-8") as fh:
fh.write("1")
deadline = _time.time() + PICK_DIR_TIMEOUT
while _time.time() < deadline:
if os.path.exists(resp):
try:
with open(resp, "r", encoding="utf-8") as fh:
val = fh.read().strip()
finally:
os.remove(resp)
return val or None
_time.sleep(0.1)
except Exception:
pass
return None
def _pick_dir_native_engine() -> Optional[str]:
"""Fallback khi không có Tauri bridge: PowerShell FolderBrowserDialog
(Windows), osascript (macOS), zenity/kdialog (Linux)."""
if os.name == "nt":
ps = (
"Add-Type -AssemblyName System.Windows.Forms; "
"$f = New-Object System.Windows.Forms.FolderBrowserDialog; "
"$f.Description = 'Chọn thư mục chứa VST / SoundFont'; "
"if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }"
)
try:
r = subprocess.run(
["powershell", "-NoProfile", "-STA", "-Command", ps],
capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
)
return r.stdout.strip() or None
except Exception:
return None
if sys.platform == "darwin":
try:
r = subprocess.run(
["osascript", "-e",
'POSIX path of (choose folder with prompt "Chọn thư mục plugin")'],
capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
)
return r.stdout.strip() or None
except Exception:
return None
for cmd in (["zenity", "--file-selection", "--directory"],
["kdialog", "--getexistingdirectory", os.path.expanduser("~")]):
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT)
if r.returncode == 0:
p = r.stdout.strip()
if p:
return p
except Exception:
continue
return None
@router.post("/pick-dir")
def pick_plugin_directory():
"""Mở NATIVE folder picker. Không cần auth (desktop local, chỉ mở dialog).
Trả {"path": "
"} hoặc {"path": None} (hủy/không có dialog).
Định nghĩa def (sync) → FastAPI chạy trong threadpool — không block loop
trong lúc user chọn thư mục (có thể mất phút)."""
path = _pick_dir_via_tauri_bridge()
if path is None:
path = _pick_dir_native_engine()
return {"path": path}
@router.get("/default-soundfonts")
async def list_default_soundfonts():
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
result = []
if os.path.isdir(static_sf_dir):
for f in os.listdir(static_sf_dir):
if f.endswith(".sf2") or f.endswith(".sf3"):
result.append({
"id": os.path.splitext(f)[0],
"name": f,
"file": f,
"url": f"/soundfonts/{f}"
})
return result
@router.get("/soundfonts/catalog")
async def soundfont_catalog(current_user: dict = Depends(get_current_user)):
scanner = get_scanner()
full_catalog = scanner.get_catalog()
inspector = get_inspector()
condensed_catalog = inspector.get_condensed_catalog_summary(full_catalog)
return {"full_catalog": full_catalog, "condensed_catalog": condensed_catalog}
@router.get("/soundfont-instruments/{sf_id}")
async def list_soundfont_instruments(sf_id: str, current_user: dict = Depends(get_current_user)):
d = _effective_dirs()
# extra_vst_dirs = plugin_dirs user (chứa cả VST lẫn SoundFont) → tìm sf2 ở đó
pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR, extra_vst_dirs=d["plugin_dirs"])
presets = pm.list_soundfont_instruments(sf_id)
return {"presets": presets, "count": len(presets)}
@router.post("/upload-soundfont")
async def upload_soundfont(
file: UploadFile = File(...),
background_tasks: BackgroundTasks = None,
current_user: dict = Depends(get_current_user)
):
enforce_password_changed(current_user)
if not (file.filename and (file.filename.endswith(".sf2") or file.filename.endswith(".sf3"))):
raise HTTPException(status_code=400, detail="Only .sf2 / .sf3 files are allowed")
# Stream upload in chunks with a hard size cap (SGM-class fonts can exceed
# 500MB; reading the whole body into RAM would OOM the server).
MAX_SF_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB
contents = bytearray()
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
contents.extend(chunk)
if len(contents) > MAX_SF_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="SoundFont quá lớn (giới hạn 2GB)")
if not PluginManager.validate_sf2_header(bytes(contents[:4096])):
raise HTTPException(status_code=400, detail="Invalid SoundFont file: missing RIFF/sfbk header")
file_ext = os.path.splitext(file.filename)[1]
file_uuid = str(uuid.uuid4())
# Store original name in a sidecar file
base_name = os.path.splitext(file.filename)[0].replace('/', '_').replace('\\', '_')
file_id = file_uuid + file_ext
dest_path = os.path.join(UPLOAD_SF_DIR, file_id)
with open(dest_path, "wb") as f:
f.write(contents)
# Save metadata with original name
meta_path = os.path.join(UPLOAD_SF_DIR, file_uuid + ".meta")
with open(meta_path, "w", encoding="utf-8") as f:
import json
json.dump({"original_name": file.filename, "uuid": file_uuid, "file": file_id}, f)
scanner = get_scanner()
if background_tasks:
background_tasks.add_task(scanner.scan_once)
else:
scanner.scan_once()
return {"id": file_id, "name": file.filename, "path": dest_path, "size_bytes": len(contents)}
@router.delete("/soundfont/{sf_id}")
async def delete_soundfont(sf_id: str, current_user: dict = Depends(get_current_user)):
base_id = sf_id.replace("sf_", "")
deleted = False
for d in [UPLOAD_SF_DIR, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts"), SYSTEM_SF_DIR]:
if not os.path.isdir(d):
continue
for f in os.listdir(d):
if os.path.splitext(f)[0] == base_id:
# Skip system dir — only allow deleting uploads
if d == SYSTEM_SF_DIR:
raise HTTPException(status_code=403, detail="System soundfonts cannot be deleted via this endpoint")
path = os.path.join(d, f)
os.remove(path)
meta_path = os.path.join(d, os.path.splitext(f)[0] + ".meta")
if os.path.isfile(meta_path):
os.remove(meta_path)
deleted = True
break
if deleted:
break
if not deleted:
raise HTTPException(status_code=404, detail="SoundFont not found")
get_scanner().scan_once()
return {"deleted": True, "sf_id": sf_id}
@router.get("/soundfonts/download/{sf_id}")
async def download_soundfont_asset(sf_id: str):
clean_id = sf_id.replace("sf_", "") if sf_id.startswith("sf_") else sf_id
# Cũng tìm trong static/soundfonts (font bundled theo deployment) — trước
# đây chỉ UPLOAD + SYSTEM → font bundled 404 → incognito (IndexedDB rỗng)
# không tải được font → instrument CÂM (browser thường dùng cache nên OK).
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
for base_dir in [UPLOAD_SF_DIR, SYSTEM_SF_DIR, static_sf_dir]:
if not os.path.isdir(base_dir):
continue
# Prefer SF2: the client FluidSynth WASM cannot decode SF3 (Ogg Vorbis)
# samples, so any SF3 would play silence in the browser.
for fname in os.listdir(base_dir):
fbase, fext = os.path.splitext(fname)
if fext.lower() == ".sf2" and fbase.lower() == clean_id.lower():
full = os.path.join(base_dir, fname)
return FileResponse(full, media_type="application/octet-stream", filename="soundfont.sf2")
# Only an SF3 exists -> decompress it to a playable SF2 on demand (cached)
for fname in os.listdir(base_dir):
fbase, fext = os.path.splitext(fname)
if fext.lower() == ".sf3" and fbase.lower() == clean_id.lower():
full = os.path.join(base_dir, fname)
try:
from app.core.soundfont_converter import SoundFontConverter
sf2_path = os.path.join(UPLOAD_SF_DIR, clean_id + ".sf2")
if os.path.exists(sf2_path) and os.path.getmtime(sf2_path) >= os.path.getmtime(full):
return FileResponse(sf2_path, media_type="application/octet-stream", filename="soundfont.sf2")
result = SoundFontConverter().sf3_to_sf2(full, sf2_path)
if result != full and os.path.exists(result):
return FileResponse(result, media_type="application/octet-stream", filename="soundfont.sf2")
except Exception as e:
print(f"[soundfont-download] SF3->SF2 conversion failed for {full}: {e}")
return FileResponse(full, media_type="application/octet-stream", filename="soundfont.sf3")
raise HTTPException(status_code=404, detail="SoundFont asset not found")
class RenderRequest(BaseModel):
project_json: dict
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)
class CarlaMidiRequest(BaseModel):
"""Gửi MIDI note từ track ARM → Carla (OSC /Carla/0/note_on|note_off).
Carla standalone bật OSC UDP mặc định cổng 22752 (source: CarlaEngineOsc,
CarlaEngineData oscPortUDP=22752; override: env CARLA_OSC_UDP_PORT của
Carla, hoặc SF_CARLA_OSC_PORT / osc_port trong carla_path.json của app).
Plugin đầu tiên trong project .carxs do app sinh có pluginId = 0."""
event: str # "note_on" | "note_off"
note: int
velocity: Optional[int] = 100
channel: Optional[int] = 0
@router.post("/carla-midi")
async def carla_midi(req: CarlaMidiRequest):
"""MIDI keyboard (piano roll / keybed) → Carla để preview VSTi realtime."""
if req.event not in ("note_on", "note_off"):
raise HTTPException(status_code=400, detail="event phải là note_on hoặc note_off")
ok = _send_carla_osc(req.event, req.note, req.velocity if req.event == "note_on" else 0, req.channel)
if not ok:
raise HTTPException(
status_code=502,
detail="Không gửi được OSC tới Carla — Carla đã mở chưa? (cổng OSC UDP mặc định 22752; "
"nếu đổi cổng trong Carla, đặt SF_CARLA_OSC_PORT hoặc osc_port trong carla_path.json)",
)
return {"success": True, "event": req.event, "note": req.note, "channel": req.channel}
def _carla_osc_port() -> int:
"""Cổng OSC UDP của Carla: SF_CARLA_OSC_PORT env → osc_port trong
storage/carla_path.json → mặc định 22752 (CarlaEngineData)."""
try:
p = os.environ.get("SF_CARLA_OSC_PORT")
if p:
return int(p)
except Exception:
pass
try:
from app.core.runtime import _carla_config_path
with open(_carla_config_path(), "r", encoding="utf-8") as f:
data = json.load(f)
p = data.get("osc_port")
if p:
return int(p)
except Exception:
pass
return 22752
def _send_carla_osc(event: str, note: int, velocity: int, channel: int) -> bool:
"""Gửi OSC UDP tới `/Carla/0/{event}` (plugin đầu tiên = plugin auto-load).
OSC message: path + typetag + int args, mỗi phần pad '\0' tới bội số 4.
Đã xác minh từ source Carla: handleMsgNoteOn/NoteOff nhận `iii`/`ii` và
tên client mặc định của app standalone là "Carla" (carla_host.py
fClientName = CARLA_CLIENT_NAME or "Carla")."""
try:
import socket
import struct
port = _carla_osc_port()
path = f"/Carla/0/{event}".encode("utf-8")
typetag = b",iii" if event == "note_on" else b",ii"
vals = [int(channel), int(note), int(velocity)] if event == "note_on" else [int(channel), int(note)]
def _pad(b: bytes) -> bytes:
rem = len(b) % 4
return b + b"\x00" * (4 - rem) if rem else b
msg = _pad(path) + _pad(typetag) + b"".join(struct.pack(">i", v) for v in vals)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0.5)
s.sendto(msg, ("127.0.0.1", port))
s.close()
return True
except Exception:
return False
@router.post("/open-in-carla")
async def open_in_carla(req: OpenInCarlaRequest, current_user: dict = Depends(get_current_user)):
"""Mở Carla với VSTi đã chọn — TỰ ĐỘNG load plugin (native GUI + keyboard).
Cơ chế: sinh file project .carxs (định dạng XML chính thức của Carla —
`carla.exe [FILE]` nhận project file) chứa node VST3
... → Carla mở lên là plugin đã load sẵn,
kèm on-screen MIDI keyboard (PixmapKeyboard) để preview realtime.
Carla là app ngoài do user tự giải nén (GPL-2.0+ → không bundle/nhúng);
app chỉ spawn tiến trình + trao đổi file preset."""
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 = ""
carxs_path = ""
if plugin_path:
carxs_path = _write_carla_project(req.plugin_name or os.path.basename(plugin_path), plugin_path)
cmd = [carla]
if carxs_path:
cmd.append(carxs_path)
try:
cwd = os.path.dirname(carla) or None
proc = subprocess.Popen(cmd, cwd=cwd, close_fds=os.name != "nt")
_register_carla_process(proc)
return {
"success": True,
"started": True,
"carla_path": carla,
"plugin_path": plugin_path,
"project_file": carxs_path,
"cmd": cmd,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Không mở được Carla: {e}")
# ── Carla bridge lifecycle ─────────────────────────────────────────────────
# App spawn Carla (open_in_carla) và PHẢI có khả năng dừng nó khi track chuyển
# sang instrument KHÔNG phải VST (soundfont/GM) — nếu không, Carla vẫn chạy và
# MIDI vẫn vào VSTi cũ → âm sai instrument + âm kẹt không dừng được.
_CARLA_PROCESSES = [] # list[subprocess.Popen]
def _register_carla_process(proc):
"""Lưu handle tiến trình Carla do app spawn (để carla-stop terminate được)."""
global _CARLA_PROCESSES
_prune_carla_processes()
_CARLA_PROCESSES.append(proc)
def _prune_carla_processes():
"""Bỏ các handle đã thoát (poll() trả code) — tránh list rác."""
global _CARLA_PROCESSES
_CARLA_PROCESSES = [p for p in _CARLA_PROCESSES if p is not None and p.poll() is None]
def _send_carla_all_notes_off() -> bool:
"""Gửi note_off TẤT CẢ pitch (0-127) trên mọi channel (0-15) tới Carla.
Dừng mọi âm đang ngân trong Carla (kể cả sustain) — dùng ngay trước khi
terminate để không còn tiếng kẹt khi bridge bị unload."""
ok = False
try:
for ch in range(16):
for note in range(128):
if _send_carla_osc("note_off", note, 0, ch):
ok = True
except Exception:
pass
return ok
@router.get("/carla-status")
async def carla_status():
"""Kiểm tra Carla bridge còn sống không (do app spawn) + cổng OSC đang dùng.
Frontend dùng để quyết định: route MIDI item EXCLUSIVE qua Carla (chỉ khi
Carla đang chạy) hay fallback FluidSynth (luôn có âm) — tránh câm toàn
phần khi Carla bị đóng."""
_prune_carla_processes()
return {
"success": True,
"running": len(_CARLA_PROCESSES) > 0,
"osc_port": _carla_osc_port(),
}
@router.post("/carla-stop")
async def carla_stop(authorization: Optional[str] = Header(None)):
"""Unload Carla bridge: tắt mọi note đang ngân + terminate tiến trình Carla
do app spawn. Gọi khi track chuyển từ VSTi sang instrument khác (soundfont)
để âm KHÔNG còn play qua Carla bridge."""
# Auth là optional (desktop app có thể chưa login) — chỉ cần decode nếu có
try:
if authorization and authorization.startswith("Bearer "):
from app.core.auth import decode_token
decode_token(authorization.split(" ")[1])
except Exception:
pass
# 1. Tắt hết âm đang ngân trong Carla (trước khi giết tiến trình)
try:
_send_carla_all_notes_off()
except Exception:
pass
# 2. Terminate tiến trình Carla đã spawn
killed = 0
_prune_carla_processes()
for proc in list(_CARLA_PROCESSES):
try:
proc.terminate()
except Exception:
pass
# Chờ tiến trình thoát (tối đa ~2s) rồi kill mạnh nếu còn sống
try:
import time as _t
deadline = _t.time() + 2.0
for proc in list(_CARLA_PROCESSES):
while proc.poll() is None and _t.time() < deadline:
_t.sleep(0.05)
if proc.poll() is None:
try:
proc.kill()
except Exception:
pass
if proc.poll() is not None:
killed += 1
except Exception:
pass
_CARLA_PROCESSES.clear()
return {
"success": True,
"stopped": True,
"killed": killed,
"all_notes_off": True,
}
def _write_carla_project(plugin_name: str, plugin_path: str) -> str:
"""Sinh file project .carxs cho Carla load sẵn VSTi (chỉ VST3 — VST2 cần
uniqueID không đoán được → mở Carla trống để user tự Add Plugin).
Định dạng theo source Carla (CarlaEngine::saveProjectInternal +
CarlaStateSave::dumpToMemoryStream): root ,
VST3pathYes10x0."""
if not plugin_path or not os.path.exists(plugin_path):
return ""
low = plugin_path.lower()
is_vst3 = low.endswith(".vst3") or low.endswith(".vst3/") or "\\" in plugin_path and plugin_path.rstrip("\\/").lower().endswith(".vst3")
if not is_vst3:
# VST2 (.dll/.so ngoài .vst3): không auto-load được tin cậy → Carla trống
return ""
from xml.sax.saxutils import escape
name = escape(plugin_name or os.path.splitext(os.path.basename(plugin_path))[0])
binary = escape(plugin_path)
# Patchbay: kết nối TỰ ĐỘNG MIDI input + audio output → default speaker.
# Mặc định Carla KHÔNG connect khi load project (chỉ restore connection có
# trong file) → lần đầu mở không có âm, không nhận MIDI (phải unload/load
# lại mới auto-connect như thêm plugin thủ công). Connection nào trỏ tới
# port không tồn tại sẽ bị Carla bỏ qua im lặng → thêm nhiều biến thể tên.
pb_name = name
patchbay = (
" \n"
# Audio out plugin → loa mặc định (system:playback_1/2)
f" {pb_name}:audio_out1system:playback_1\n"
f" {pb_name}:audio_out2system:playback_2\n"
# MIDI input → plugin midi_in (biến thể tên client: Carla / carla / system)
f" Carla:midi_in{pb_name}:midi_in\n"
f" carla:midi_in{pb_name}:midi_in\n"
f" system:midi_capture_1{pb_name}:midi_in\n"
" \n"
)
xml = (
"\n"
"\n"
f"\n"
" \n"
" false\n"
" false\n"
" false\n"
" false\n"
" 100\n"
" 10000\n"
" \n"
" \n"
" \n"
f" VST3\n"
f" {name}\n"
f" {binary}\n"
f" \n"
" \n"
" \n"
" Yes\n"
" 1\n"
" 0x0\n"
" \n"
" \n"
+ patchbay +
"\n"
)
try:
proj_dir = os.path.join(settings.STORAGE_DIR, "carla_projects")
os.makedirs(proj_dir, exist_ok=True)
# Dọn project cũ (quá 1 ngày) — tránh rác
try:
now = _time.time()
for f in os.listdir(proj_dir):
fp = os.path.join(proj_dir, f)
try:
if os.path.isfile(fp) and now - os.path.getmtime(fp) > 86400:
os.remove(fp)
except Exception:
pass
except Exception:
pass
safe = "".join(c for c in plugin_name if c.isalnum() or c in " _-")[:40].strip() or "plugin"
carxs = os.path.join(proj_dir, f"{safe}_{uuid.uuid4().hex[:8]}.carxs")
with open(carxs, "w", encoding="utf-8") as fh:
fh.write(xml)
return carxs
except Exception:
return ""
@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:
out_path, duration_sec = _render_midi_notes_pedalboard(
instrument_id=req.instrument_id,
notes=req.notes,
bpm=req.bpm,
sample_rate=req.sample_rate,
preset_id=req.preset_id,
preset_path=req.preset_path,
preset_data_b64=req.preset_data,
soundfont_bank=req.soundfont_bank,
soundfont_program=req.soundfont_program,
)
fname = os.path.basename(out_path)
return {
"success": True,
"url": f"/static/audio/processed/{fname}",
"path": out_path,
"duration_sec": round(duration_sec, 3),
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Preview thất bại: {e}")
class MidiRenderRequest(BaseModel):
"""Render MIDI notes → audio qua VSTi (âm thật, cùng code path với export).
instrument_id = plugin_id của track synth_engine (khớp key scan của
PluginManager). Đây là cầu nối Carla → pedalboard: preset chỉnh trong
Carla (.vstpreset) được áp vào pedalboard trước khi render."""
instrument_id: str
notes: list = []
bpm: float = 120.0
sample_rate: int = 44100
preset_id: Optional[str] = None
preset_path: Optional[str] = None
preset_data: Optional[str] = None # base64 bytes .vstpreset (project nhúng)
@router.post("/midi-render")
async def midi_render(req: MidiRenderRequest, current_user: dict = Depends(get_current_user)):
"""Export MIDI notes → WAV với âm của VSTi instrument (lưu vào processed).
Preview/export MIDI notes với âm VSTi trước đây KHÔNG thực hiện được:
- preview realtime chỉ phát qua loa Carla (không vào audio graph DAW)
- clientSideExport chỉ render soundfont (midiCache = FluidSynth WASM)
- /plugins/preview có sẵn nhưng frontend không gọi
Endpoint này render offline bằng pedalboard (đúng plugin + preset như
export) → trả file_id để UI preview / gán clip vào project / download."""
enforce_password_changed(current_user)
if not req.notes:
raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để render")
user_id = current_user["user_id"]
out_name = f"user_{user_id}_midi_{uuid.uuid4().hex[:10]}.wav"
out_path = os.path.join(settings.PROCESSED_DIR, out_name)
try:
out_path, duration_sec = _render_midi_notes_pedalboard(
instrument_id=req.instrument_id,
notes=req.notes,
bpm=req.bpm,
sample_rate=req.sample_rate,
preset_id=req.preset_id,
preset_path=req.preset_path,
preset_data_b64=req.preset_data,
)
return {
"success": True,
"file_id": os.path.basename(out_path),
"url": f"/static/audio/processed/{os.path.basename(out_path)}",
"path": out_path,
"duration_sec": round(duration_sec, 3),
"render_mode": "pedalboard",
}
except HTTPException:
# Không để lại file rác nếu thất bại giữa chừng
try:
if os.path.exists(out_path):
os.remove(out_path)
except Exception:
pass
raise
except Exception as e:
try:
if os.path.exists(out_path):
os.remove(out_path)
except Exception:
pass
raise HTTPException(
status_code=500,
detail=f"Render MIDI thất bại: {e}. Nếu plugin là VST2 hoặc pedalboard "
"không load được, hãy mở Carla Bridge (chọn VSTi, chỉnh âm, "
"Save preset .vstpreset) rồi Upload preset vào track — render "
"sẽ dùng đúng âm đã chỉnh.",
)
def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float,
sample_rate: int, preset_id=None, preset_path=None,
preset_data_b64=None, soundfont_bank=None,
soundfont_program=None) -> tuple:
"""Render MIDI notes qua pedalboard (VSTi + preset) → WAV trong PROCESSED_DIR.
Trả (out_path, duration_sec). Ném HTTPException khi plugin không load được."""
if not HAS_PEDALBOARD:
raise HTTPException(status_code=501, detail="pedalboard không khả dụng trên máy này")
pm = PluginManager()
vst = pm.load_vst(instrument_id)
if vst is None:
raise HTTPException(
status_code=404,
detail=f"Không tìm thấy VSTi: {instrument_id} — chưa scan thấy plugin này. "
"Kiểm tra Plugins Manager → Scan, hoặc mở Carla để load VST2 "
"(pedalboard chỉ render được VST3).",
)
apply_preset_to_plugin(
vst,
preset_id=preset_id,
preset_path=preset_path,
preset_data_b64=preset_data_b64,
)
from pedalboard import Pedalboard
midi_events = []
for n in 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, bpm, sample_rate,
bank=soundfont_bank, program=soundfont_program,
)
total_needed = 0
beat_sec = 60.0 / max(30.0, bpm)
for ev in midi_events:
end_sec = (ev["start_beat"] + ev["duration_beats"]) * beat_sec
if int(end_sec * sample_rate) > total_needed:
total_needed = int(end_sec * sample_rate)
total_needed = max(total_needed, 1024)
# pedalboard >= 0.9: Pedalboard container KHÔNG chứa instrument — gọi
# thẳng overload MIDI của plugin (overload 2: midi_messages + duration).
buf = vst(midi_messages, sample_rate=sample_rate,
duration=total_needed / float(sample_rate), num_channels=2)
out_path = os.path.join(settings.PROCESSED_DIR, f"preview_{uuid.uuid4().hex[:10]}.wav")
sf.write(out_path, buf.T, sample_rate)
return out_path, buf.shape[1] / float(sample_rate)
class CarlaPlayNotesRequest(BaseModel):
"""Phát dãy MIDI notes qua Carla bridge (OSC, realtime) — preview khi
pedalboard không render được plugin (VD VST2). Cần Carla đang chạy với
plugin đã load (open-in-carla)."""
notes: list = []
bpm: float = 120.0
channel: Optional[int] = 0
@router.post("/carla-play-notes")
async def carla_play_notes(req: CarlaPlayNotesRequest, current_user: dict = Depends(get_current_user)):
"""Phát toàn bộ dãy MIDI notes vào Carla (note_on/note_off đúng thời điểm).
Preview realtime qua đúng VSTi đang mở trong Carla — dùng khi pedalboard
không load được plugin (VST2, plugin cần state GUI). Âm phát ra loa hệ
thống (Carla), KHÔNG thu vào file — muốn file audio dùng /midi-render."""
enforce_password_changed(current_user)
if not req.notes:
raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để phát")
if not find_carla_local():
raise HTTPException(status_code=409, detail="Carla chưa được định vị (Plugin Manager → Carla Bridge → Định vị Carla...)")
if not _carla_bridge_running():
raise HTTPException(status_code=409, detail="Carla chưa mở. Hãy mở Carla Bridge (nút Synth → VSTi) trước khi preview qua Carla.")
beat_sec = 60.0 / max(30.0, req.bpm)
channel = int(req.channel or 0)
events = []
for n in req.notes:
pitch = int(n.get("pitch", 60))
vel = int(float(n.get("velocity", 0.8)) * 127)
start = float(n.get("start_beat", 0)) * beat_sec
dur = float(n.get("duration_beats", 1)) * beat_sec
events.append((start, "note_on", pitch, max(1, min(127, vel))))
events.append((start + dur, "note_off", pitch, 0))
events.sort(key=lambda e: (e[0], 0 if e[1] == "note_off" else 1))
duration_sec = max((e[0] for e in events), default=0) + 0.3
# Phát trong thread nền — endpoint trả ngay, không block tới hết bản nhạc
def _player():
import time as _t
t0 = _t.monotonic()
for ev_time, ev_type, pitch, vel in events:
wait = (t0 + ev_time) - _t.monotonic()
if wait > 0:
_t.sleep(wait)
_send_carla_osc(ev_type, pitch, vel, channel)
threading.Thread(target=_player, daemon=True).start()
return {"success": True, "mode": "carla_realtime", "duration_sec": round(duration_sec, 3), "events": len(events)}
def find_carla_local() -> str:
from app.core.runtime import find_carla as _fc
try:
return _fc() or ""
except Exception:
return ""
def _carla_bridge_running() -> bool:
try:
_prune_carla_processes()
return len(_CARLA_PROCESSES) > 0
except Exception:
return False
@router.post("/render")
async def render_project(
req: RenderRequest,
current_user: dict = Depends(get_current_user)
):
enforce_password_changed(current_user)
engine = PythonRenderEngine()
# Prevent path traversal: strip any directory components and force .wav.
safe_name = os.path.basename((req.output_filename or "render_output.wav").replace("\\", "/"))
if not safe_name.lower().endswith(".wav"):
safe_name += ".wav"
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
try:
result_path = engine.render_project(req.project_json, output_path)
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Render failed: {str(e)}")