1806 lines
79 KiB
Python
1806 lines
79 KiB
Python
import os, sys, uuid, json, tempfile, subprocess, time as _time, threading, asyncio
|
|
import numpy as np
|
|
import soundfile as sf
|
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header, Query, WebSocket, WebSocketDisconnect
|
|
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_PYFLUIDSYNTH, render_soundfont_midi_to_audio, RENDER_LOCK, _render_locked
|
|
from app.core.render_engine import PythonRenderEngine, _find_sf2_path, _find_default_sf2
|
|
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
|
|
from app.core import fx_realtime
|
|
from app.core.auth import decode_token
|
|
|
|
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 _invalidate_fx_scan_cache() -> bool:
|
|
"""Clear cache scan VST FX của PluginManager singleton.
|
|
|
|
Plugins Manager bấm Scan (POST /scan) quét dirs MỚI nhưng trước đây
|
|
không clear `_fx_scan_all` của vst_engine → MASTERING PANEL / FX Rack
|
|
vẫn trả cache cũ (thường là [] kẹt từ lúc app khởi động) → VST FX đã
|
|
load trong Plugin Manager KHÔNG hiển thị. Gọi invalidate để lần mở panel
|
|
kế tiếp quét lại với dirs mới.
|
|
"""
|
|
try:
|
|
from app.core.vst_engine import get_plugin_manager
|
|
get_plugin_manager().invalidate_fx_scan()
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
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),
|
|
"fx_cache_invalidated": _invalidate_fx_scan_cache(),
|
|
}
|
|
|
|
_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()
|
|
# Modal chọn instrument (nút Synth TCP) chỉ hiện soundfont + VSTi — loại
|
|
# VST FX (Ozone/Sonible/...) khỏi vst_instruments theo is_fx của bridge scan.
|
|
# Filter TRƯỚC merge extra: nếu không, entry FX (path .vst3) chiếm id và
|
|
# setdefault chặn entry instrument (path .dll) cùng tên → mất cả hai.
|
|
# Ngoài is_fx còn loại entry UNKNOWN (is_fx=false, is_instrument=false —
|
|
# bridge không đọc được module, vd Ozone 11 Clarity crash/hang khi scan):
|
|
# không xác định được loại → KHÔNG hiện trong nút Synth.
|
|
fx_paths = set()
|
|
unknown_paths = set()
|
|
try:
|
|
from app.core.vst_engine import get_plugin_manager
|
|
for e in get_plugin_manager().list_scan_all():
|
|
path = (e.get("path") or "").lower()
|
|
if e.get("is_fx"):
|
|
fx_paths.add(path)
|
|
elif e.get("is_unknown"):
|
|
unknown_paths.add(path)
|
|
except Exception:
|
|
pass
|
|
blocked = fx_paths | unknown_paths
|
|
if blocked:
|
|
avail["vst_instruments"] = [v for v in avail["vst_instruments"]
|
|
if (v.get("path") or "").lower() not in blocked]
|
|
# 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 = [v for v in _scan_vst_in_dirs(d["plugin_dirs"])
|
|
if (v.get("path") or "").lower() not in blocked]
|
|
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)."""
|
|
def _inside_vst3_bundle(root: str) -> bool:
|
|
# Bỏ qua file NẰM TRONG folder .vst3 (vd ...\Ozone Pro.vst3\iZOzonePro.dll):
|
|
# folder .vst3 đã được tính là 1 plugin (đúng path bridge scan trả) —
|
|
# file .dll bên trong tạo entry TRÙNG path khác → không khớp block set
|
|
# của list_scan_all → FX (Ozone/Neutron...) lọt vào nút Synth.
|
|
parts = root.replace("/", os.sep).split(os.sep)
|
|
return any(p.lower().endswith(".vst3") for p in parts)
|
|
|
|
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),
|
|
}
|
|
if _inside_vst3_bundle(root):
|
|
continue # file bên trong bundle VST3 — không phải plugin riêng
|
|
# iZotope cài kiểu: thư mục chứa CẢ file .vst3 lẫn file .dll
|
|
# (iZOzonePro.dll là module/dependency — bridge scan chỉ nhận
|
|
# .vst3 là FX). Thư mục có file .vst3 → bỏ qua .dll (VST2 thật
|
|
# như Kong Audio vẫn giữ vì thư mục không có .vst3).
|
|
has_vst3_file = any(f.lower().endswith(".vst3") for f in files)
|
|
for f in files:
|
|
low = f.lower()
|
|
if low.endswith(".vst3") or low.endswith(".so"):
|
|
name = os.path.splitext(f)[0]
|
|
if name not in found:
|
|
found[name] = {
|
|
"id": name, "name": name, "type": "VST3",
|
|
"path": os.path.join(root, f),
|
|
}
|
|
elif low.endswith(".dll") and not has_vst3_file:
|
|
name = os.path.splitext(f)[0]
|
|
if name not in found:
|
|
found[name] = {
|
|
"id": name, "name": name, "type": "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
|
|
# Bridge chi dung khi engine do Tauri spawn (co SF_PARENT_PID). Marker co
|
|
# the con sot tu lan Tauri chay truoc → chay standalone se cho response
|
|
# khong bao gio den (treo 120s). Check nay loai bo truong hop do.
|
|
if not os.environ.get("SF_PARENT_PID"):
|
|
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": "<thư mục>"} 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 FxGuiRequest(BaseModel):
|
|
"""Mở native GUI của VST FX qua bridge (Windows desktop)."""
|
|
path: str
|
|
name: Optional[str] = None
|
|
# embed=True → bridge --fx-gui (off-screen + HTTP frame/input server,
|
|
# PLAN_MASTERBUS_VST_GUI_EMBED.md); embed=False → cửa sổ rời cũ.
|
|
embed: bool = False
|
|
|
|
|
|
class FxGuiCloseRequest(BaseModel):
|
|
"""Đóng bridge fx-gui đã mở cho plugin (embed mode)."""
|
|
path: str
|
|
|
|
|
|
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 _carla_osc_ready() -> bool:
|
|
"""Carla OSC engine đang lắng nghe cổng UDP chưa — thử bind socket vào đúng
|
|
cổng. Bind THÀNH CÔNG → cổng trống (Carla chưa mở / OSC chưa sẵn sàng);
|
|
EADDRINUSE → Carla đang giữ cổng (kể cả mở thủ công ngoài app, hoặc app
|
|
spawn đang boot tới giai đoạn OSC). KHÔNG set SO_REUSEADDR (Linux/Win cho
|
|
phép 2 UDP socket cùng port → mất tác dụng phát hiện)."""
|
|
try:
|
|
import socket
|
|
port = _carla_osc_port()
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
s.bind(("127.0.0.1", port))
|
|
s.close()
|
|
return False
|
|
except OSError:
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
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 <Plugin><Info><Type>VST3
|
|
</Type><Binary>...</Binary></Info> → 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)
|
|
# ⚠️ FIX (Bug 2): Carla ĐÃ chạy (mở thủ công / app spawn từ trước, hoặc
|
|
# đang boot) → KHÔNG spawn bản 2 (trùng OSC port 22752 + audio → bridge
|
|
# hỏng; Carla là single-instance). Trả already_running — frontend poll
|
|
# carla-status chờ OSC ready rồi mới gửi note.
|
|
if _carla_osc_ready() or len(_CARLA_PROCESSES) > 0:
|
|
return {
|
|
"success": True,
|
|
"started": False,
|
|
"already_running": True,
|
|
"carla_path": carla,
|
|
"plugin_path": plugin_path,
|
|
"project_file": carxs_path,
|
|
"cmd": [],
|
|
}
|
|
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}")
|
|
|
|
@router.post("/fx-gui")
|
|
async def open_fx_gui(req: FxGuiRequest, current_user: dict = Depends(get_current_user)):
|
|
"""Mở native GUI của VST FX (mastering chain / FX rack) qua bridge.
|
|
|
|
Bridge có mode `--open-fx-gui <job.json>` (Windows): spawn tiến trình riêng
|
|
giữ vòng lặp message pump, tạo window 800x600 + attach IPlugView của plugin.
|
|
Đây là GUI tách rời — user chỉnh thông số, đóng window là bridge thoát
|
|
(exit 0). Không spawn 2 lần cho cùng plugin đang mở (tránh trùng GUI).
|
|
"""
|
|
enforce_password_changed(current_user)
|
|
from app.core.native_render import find_fx_bridge_exe
|
|
exe = find_fx_bridge_exe()
|
|
if not exe:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Không tìm thấy bridge native (fx_vst_bridge.exe). "
|
|
"Hãy chạy bản desktop đã build đầy đủ.",
|
|
)
|
|
plugin_path = (req.path or "").strip()
|
|
if not plugin_path:
|
|
raise HTTPException(status_code=400, detail="Thiếu đường dẫn VST plugin.")
|
|
# Tránh mở 2 GUI cùng plugin: nếu tiến trình bridge fx-gui cho plugin này
|
|
# đang chạy → trả started=False (frontend có thể focus window cũ).
|
|
for proc in _FX_GUI_PROCESSES:
|
|
if proc is not None and proc.poll() is None and getattr(proc, "_sf_plugin_path", None) == plugin_path:
|
|
return {"success": True, "started": False, "already_running": True, "cmd": [], "embed_url": getattr(proc, "_sf_embed_url", None) or None}
|
|
job = {"path": plugin_path, "name": req.name or os.path.basename(plugin_path)}
|
|
fd, job_path = tempfile.mkstemp(prefix="sf_fx_gui_", suffix=".json", dir=tempfile.gettempdir())
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
json.dump(job, f)
|
|
except Exception:
|
|
os.close(fd)
|
|
raise HTTPException(status_code=500, detail="Không ghi được job file cho bridge.")
|
|
cmd = [exe, "--fx-gui" if req.embed else "--open-fx-gui", job_path]
|
|
try:
|
|
if req.embed:
|
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, text=True,
|
|
close_fds=os.name != "nt",
|
|
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
|
|
# Bridge prints SF_FXGUI_PORT=<port> after binding its HTTP server.
|
|
embed_url = None
|
|
try:
|
|
for line in proc.stdout:
|
|
line = (line or "").strip()
|
|
if line.startswith("SF_FXGUI_PORT="):
|
|
port = int(line.split("=", 1)[1])
|
|
embed_url = f"http://127.0.0.1:{port}"
|
|
break
|
|
except Exception:
|
|
pass
|
|
if not embed_url:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except Exception:
|
|
proc.kill()
|
|
raise HTTPException(status_code=500, detail="Bridge không báo port fx-gui (mở GUI thất bại).")
|
|
proc._sf_plugin_path = plugin_path
|
|
proc._sf_embed_url = embed_url
|
|
_register_fx_gui_process(proc)
|
|
return {"success": True, "started": True, "already_running": False,
|
|
"embed_url": embed_url, "cmd": cmd}
|
|
proc = subprocess.Popen(cmd, close_fds=os.name != "nt",
|
|
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0)
|
|
proc._sf_plugin_path = plugin_path
|
|
_register_fx_gui_process(proc)
|
|
return {"success": True, "started": True, "already_running": False, "cmd": cmd}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
try:
|
|
os.remove(job_path)
|
|
except Exception:
|
|
pass
|
|
raise HTTPException(status_code=500, detail=f"Không mở được GUI của VST: {e}")
|
|
|
|
|
|
@router.post("/fx-gui/close")
|
|
async def close_fx_gui(req: FxGuiCloseRequest, current_user: dict = Depends(get_current_user)):
|
|
"""Đóng bridge fx-gui đang chạy cho plugin (embed mode) — frontend gọi khi
|
|
user đóng panel embed. Không lỗi nếu plugin chưa mở (closed=False)."""
|
|
enforce_password_changed(current_user)
|
|
plugin_path = (req.path or "").strip()
|
|
if not plugin_path:
|
|
raise HTTPException(status_code=400, detail="Thiếu đường dẫn VST plugin.")
|
|
for proc in _FX_GUI_PROCESSES:
|
|
if proc is not None and proc.poll() is None and getattr(proc, "_sf_plugin_path", None) == plugin_path:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except Exception:
|
|
proc.kill()
|
|
_prune_fx_gui_processes()
|
|
return {"success": True, "closed": True}
|
|
return {"success": True, "closed": False}
|
|
|
|
|
|
_FX_GUI_PROCESSES = [] # list[subprocess.Popen] — các bridge fx-gui do app spawn
|
|
|
|
def _register_fx_gui_process(proc):
|
|
global _FX_GUI_PROCESSES
|
|
_prune_fx_gui_processes()
|
|
_FX_GUI_PROCESSES.append(proc)
|
|
|
|
def _prune_fx_gui_processes():
|
|
global _FX_GUI_PROCESSES
|
|
_FX_GUI_PROCESSES = [p for p in _FX_GUI_PROCESSES if p is not None and p.poll() is None]
|
|
|
|
|
|
|
|
# ── 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 (OSC engine đang nghe cổng UDP —
|
|
phát hiện CẢ Carla mở thủ công ngoài app, không chỉ app-spawn; chính xác
|
|
hơn _CARLA_PROCESSES vì OSC bind khi Carla boot xong engine).
|
|
|
|
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. running=OSC ready → gửi note là tới được Carla."""
|
|
_prune_carla_processes()
|
|
return {
|
|
"success": True,
|
|
"running": _carla_osc_ready(),
|
|
"app_spawned": 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 <CARLA-PROJECT VERSION='2.5'>,
|
|
<Plugin><Info><Type>VST3</Type><Binary>path</Binary><Label>..</Label>
|
|
</Info><Data><Active>Yes</Active><ControlChannel>1</ControlChannel>
|
|
<Options>0x0</Options></Data></Plugin>."""
|
|
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 = (
|
|
" <Patchbay>\n"
|
|
# Audio out plugin → loa mặc định (system:playback_1/2)
|
|
f" <Connection><Source>{pb_name}:audio_out1</Source><Target>system:playback_1</Target></Connection>\n"
|
|
f" <Connection><Source>{pb_name}:audio_out2</Source><Target>system:playback_2</Target></Connection>\n"
|
|
# MIDI input → plugin midi_in (biến thể tên client: Carla / carla / system)
|
|
f" <Connection><Source>Carla:midi_in</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
|
|
f" <Connection><Source>carla:midi_in</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
|
|
f" <Connection><Source>system:midi_capture_1</Source><Target>{pb_name}:midi_in</Target></Connection>\n"
|
|
" </Patchbay>\n"
|
|
)
|
|
xml = (
|
|
"<?xml version='1.0' encoding='UTF-8'?>\n"
|
|
"<!DOCTYPE CARLA-PROJECT>\n"
|
|
f"<CARLA-PROJECT VERSION='2.5'>\n"
|
|
" <EngineSettings>\n"
|
|
" <ForceStereo>false</ForceStereo>\n"
|
|
" <PreferPluginBridges>false</PreferPluginBridges>\n"
|
|
" <PreferUiBridges>false</PreferUiBridges>\n"
|
|
" <UIsAlwaysOnTop>false</UIsAlwaysOnTop>\n"
|
|
" <MaxParameters>100</MaxParameters>\n"
|
|
" <UIBridgesTimeout>10000</UIBridgesTimeout>\n"
|
|
" </EngineSettings>\n"
|
|
" <Plugin>\n"
|
|
" <Info>\n"
|
|
f" <Type>VST3</Type>\n"
|
|
f" <Name>{name}</Name>\n"
|
|
f" <Binary>{binary}</Binary>\n"
|
|
f" <Label>{name}</Label>\n"
|
|
" </Info>\n"
|
|
" <Data>\n"
|
|
" <Active>Yes</Active>\n"
|
|
" <ControlChannel>1</ControlChannel>\n"
|
|
" <Options>0x0</Options>\n"
|
|
" </Data>\n"
|
|
" </Plugin>\n"
|
|
+ patchbay +
|
|
"</CARLA-PROJECT>\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)."""
|
|
# SF_RENDER_ENGINE=bridge: preview qua native_bridge, không cần pedalboard
|
|
if settings.RENDER_ENGINE != "bridge":
|
|
raise HTTPException(status_code=501, detail="Chỉ hỗ trợ native_bridge render — set SF_RENDER_ENGINE=bridge")
|
|
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": settings.RENDER_ENGINE,
|
|
}
|
|
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 VSTi không load được "
|
|
"qua native_bridge, 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.",
|
|
)
|
|
|
|
|
|
@_render_locked
|
|
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 (VSTi + preset) → WAV trong PROCESSED_DIR — qua
|
|
native_bridge --render (SF_RENDER_ENGINE=bridge; pedalboard GPL-3.0 đã gỡ).
|
|
|
|
Trả (out_path, duration_sec). Ném HTTPException khi plugin không load được."""
|
|
if settings.RENDER_ENGINE != "bridge":
|
|
raise HTTPException(status_code=501,
|
|
detail="Chỉ hỗ trợ native_bridge render — set SF_RENDER_ENGINE=bridge")
|
|
from app.core import native_render
|
|
return native_render.render_offline(
|
|
instrument_id=instrument_id,
|
|
notes=notes,
|
|
bpm=bpm,
|
|
sample_rate=sample_rate,
|
|
preset_id=preset_id,
|
|
preset_path=preset_path,
|
|
preset_data_b64=preset_data_b64,
|
|
soundfont_bank=soundfont_bank,
|
|
soundfont_program=soundfont_program,
|
|
)
|
|
|
|
|
|
@router.get("/fx")
|
|
async def list_fx_plugins(current_user: dict = Depends(get_current_user),
|
|
refresh: bool = Query(False)):
|
|
"""List VST effect plugins (is_fx) — native_bridge --scan trên các dir.
|
|
|
|
Chỉ plugin VST3 effect (Ozone, Scaler Audio, ...); instrument/VST2 bị
|
|
bridge loại. refresh=true → quét lại (Plugins Manager → Scan)."""
|
|
from app.core.vst_engine import get_plugin_manager
|
|
pm = get_plugin_manager()
|
|
return {"success": True, "plugins": pm.list_fx(refresh=refresh)}
|
|
|
|
|
|
class FxRenderRequest(BaseModel):
|
|
"""Render WAV qua chuỗi FX (VST3 effect + builtin gain/normalize).
|
|
|
|
Dùng cho masterbus (render xong project → Ozone trên bus) và FX Rack
|
|
per-track (preview offline qua bridge; realtime vẫn WebAudio)."""
|
|
input_file_id: Optional[str] = None # file trong PROCESSED_DIR
|
|
input_path: Optional[str] = None # path tuyệt đối (test)
|
|
fx_chain: list = []
|
|
sample_rate: int = 44100
|
|
|
|
|
|
@router.post("/fx-render")
|
|
async def fx_render(req: FxRenderRequest, current_user: dict = Depends(get_current_user)):
|
|
"""Áp fx_chain lên WAV đầu vào → WAV mới trong PROCESSED_DIR."""
|
|
enforce_password_changed(current_user)
|
|
src = (req.input_path or "").strip()
|
|
if not src:
|
|
fid = (req.input_file_id or "").strip()
|
|
if not fid:
|
|
raise HTTPException(status_code=400,
|
|
detail="Cần input_file_id hoặc input_path")
|
|
# File từ client upload (audio/upload → UPLOADS_DIR) hoặc đã xử lý
|
|
# (PROCESSED_DIR) — resolve cả hai như audio.download.
|
|
fid = os.path.basename(fid)
|
|
src = os.path.join(settings.PROCESSED_DIR, fid)
|
|
if not os.path.isfile(src):
|
|
src = os.path.join(settings.UPLOADS_DIR, fid)
|
|
if not os.path.isfile(src):
|
|
raise HTTPException(status_code=404,
|
|
detail=f"File đầu vào không tồn tại: {src}")
|
|
user_id = current_user["user_id"]
|
|
out_name = f"user_{user_id}_fx_{uuid.uuid4().hex[:10]}.wav"
|
|
out_path = os.path.join(settings.PROCESSED_DIR, out_name)
|
|
try:
|
|
_render_fx_locked(src, req.fx_chain, req.sample_rate, out_path)
|
|
duration = sf.info(out_path).frames / max(1, req.sample_rate)
|
|
return {
|
|
"success": True,
|
|
"file_id": out_name,
|
|
"url": f"/static/audio/processed/{out_name}",
|
|
"path": out_path,
|
|
"duration_sec": round(duration, 3),
|
|
"render_mode": settings.RENDER_ENGINE,
|
|
}
|
|
except HTTPException:
|
|
try:
|
|
os.remove(out_path)
|
|
except Exception:
|
|
pass
|
|
raise
|
|
except Exception as e:
|
|
try:
|
|
os.remove(out_path)
|
|
except Exception:
|
|
pass
|
|
raise HTTPException(status_code=500, detail=f"FX render thất bại: {e}")
|
|
|
|
|
|
@_render_locked
|
|
def _render_fx_locked(input_path: str, fx_chain: list, sample_rate: int, out_path: str):
|
|
"""FX render qua native_bridge --render-fx (không thread-safe → lock)."""
|
|
if settings.RENDER_ENGINE != "bridge":
|
|
raise HTTPException(status_code=501,
|
|
detail="Chỉ hỗ trợ native_bridge render — set SF_RENDER_ENGINE=bridge")
|
|
from app.core.native_render import render_fx_chain
|
|
render_fx_chain(input_wav=input_path, fx_chain=fx_chain,
|
|
sample_rate=sample_rate, out_path=out_path)
|
|
|
|
|
|
class FxRealtimeStartRequest(BaseModel):
|
|
"""Start một session FX realtime (master chain qua fx_vst_bridge
|
|
--realtime-fx + SHM ring). Trả session_id để client mở WebSocket
|
|
/ws/fx-realtime/{session_id} đẩy/kéo block audio."""
|
|
fx_chain: list = []
|
|
sample_rate: int = 44100
|
|
|
|
class FxRealtimeStopRequest(BaseModel):
|
|
session_id: str = ""
|
|
|
|
@router.post("/fx-realtime/start")
|
|
async def fx_realtime_start(req: FxRealtimeStartRequest,
|
|
current_user: dict = Depends(get_current_user)):
|
|
"""Start bridge realtime FX loop + SHM. Client mở WS /ws/fx-realtime/{id}
|
|
(token qua query param 'token' hoặc Authorization Bearer) rồi gửi block
|
|
stereo float32 interleaved (block_size*2 float = 2048B), nhận block đã xử lý."""
|
|
enforce_password_changed(current_user)
|
|
try:
|
|
sess = fx_realtime.start_session(req.fx_chain, req.sample_rate)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500,
|
|
detail=f"Không khởi động FX realtime: {e}")
|
|
return {
|
|
"success": True,
|
|
"session_id": sess.name,
|
|
"ws_url": f"/api/v1/plugins/ws/fx-realtime/{sess.name}",
|
|
"block_size": sess.block_size,
|
|
"sample_rate": sess.sample_rate,
|
|
}
|
|
|
|
@router.post("/fx-realtime/stop")
|
|
async def fx_realtime_stop(req: FxRealtimeStopRequest,
|
|
current_user: dict = Depends(get_current_user)):
|
|
"""Dừng session realtime (running=0 → bridge thoát, SHM unlink)."""
|
|
enforce_password_changed(current_user)
|
|
ok = fx_realtime.stop_session(req.session_id or "")
|
|
return {"success": True, "stopped": ok}
|
|
|
|
@router.websocket("/ws/fx-realtime/{session_id}")
|
|
async def ws_fx_realtime(websocket: WebSocket, session_id: str):
|
|
"""Vòng lặp FX realtime: receive block input → SHM → bridge → SHM → send
|
|
block output. Binary frames: 2048B stereo float32 interleaved (256*2)."""
|
|
# Auth: token qua query param (JS WebSocket không set header dễ) hoặc
|
|
# Authorization Bearer. Desktop app có thể chưa login → token rỗng được
|
|
# phép (endpoint /start vẫn yêu cầu auth).
|
|
token = websocket.query_params.get("token", "") or ""
|
|
if not token:
|
|
auth = websocket.headers.get("authorization", "")
|
|
if auth.lower().startswith("bearer "):
|
|
token = auth.split(" ", 1)[1].strip()
|
|
if token:
|
|
try:
|
|
payload = decode_token(token)
|
|
except Exception:
|
|
payload = None
|
|
if not payload:
|
|
await websocket.close(code=4401)
|
|
return
|
|
sess = fx_realtime.get_session(session_id)
|
|
if not sess:
|
|
await websocket.close(code=4404)
|
|
return
|
|
await websocket.accept()
|
|
# Output pump riêng: bridge sản xuất block KHÔNG đồng bộ với input client —
|
|
# nếu chỉ drain trong vòng receive_bytes, output thừa kẹt lại trong SHM ring
|
|
# (client ngừng gửi → server block ở receive → âm tắc).
|
|
async def _pump_output():
|
|
# Pace mỗi block theo tốc độ âm thật (256/sr ~ 5.8ms): bridge sản xuất
|
|
# output theo burst khi nhận input dồn; bắn toàn bộ cùng lúc → worklet
|
|
# overflow drop block cũ nhất → âm ngắt quãng.
|
|
block_sec = (sess.block_size or 256) / max(int(sess.sample_rate or 44100), 1)
|
|
try:
|
|
while True:
|
|
blks = sess.read_output()
|
|
if not blks:
|
|
await asyncio.sleep(0.002)
|
|
continue
|
|
for blk in blks:
|
|
await websocket.send_bytes(blk)
|
|
await asyncio.sleep(block_sec)
|
|
except Exception:
|
|
pass
|
|
pump_task = asyncio.create_task(_pump_output())
|
|
try:
|
|
while True:
|
|
data = await websocket.receive_bytes()
|
|
if not sess.alive():
|
|
break
|
|
sess.write_input(data)
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
pump_task.cancel()
|
|
except Exception:
|
|
pass
|
|
fx_realtime.stop_session(session_id)
|
|
|
|
class SoundfontRenderRequest(BaseModel):
|
|
"""Render MIDI notes → WAV bằng soundfont THẬT qua native FluidSynth
|
|
(pyfluidsynth) — luồng âm instrument cho môi trường STANDALONE (xử lí
|
|
trực tiếp trên OS; docker dùng FluidSynthWASM ở client)."""
|
|
soundfont_id: Optional[str] = None
|
|
soundfont_path: Optional[str] = None
|
|
bank: int = 0
|
|
program: int = 0
|
|
notes: list = []
|
|
bpm: float = 120.0
|
|
sample_rate: int = 44100
|
|
|
|
|
|
@router.post("/soundfont-render")
|
|
async def soundfont_render(req: SoundfontRenderRequest, current_user: dict = Depends(get_current_user)):
|
|
"""Preview/play MIDI notes bằng âm soundfont thật — native FluidSynth trên OS.
|
|
|
|
Khác FluidSynthWASM (docker): âm render server-side bằng pyfluidsynth, trả
|
|
WAV để client play. Dùng cho: preview/keyboard, play MIDI item khi chạy
|
|
transport ở môi trường standalone."""
|
|
enforce_password_changed(current_user)
|
|
if not req.notes:
|
|
raise HTTPException(status_code=400, detail="Chưa có nốt nhạc để render")
|
|
if not HAS_PYFLUIDSYNTH:
|
|
raise HTTPException(status_code=501, detail="pyfluidsynth không khả dụng — không render được soundfont native")
|
|
# Resolve path: soundfont_path → soundfont_id (upload/user dirs) → default
|
|
sf_path = (req.soundfont_path or "").strip()
|
|
if not sf_path and req.soundfont_id:
|
|
sf_path = _find_sf2_path(req.soundfont_id)
|
|
if not sf_path or not os.path.exists(sf_path):
|
|
sf_path = _find_default_sf2()
|
|
if not sf_path or not os.path.exists(sf_path):
|
|
raise HTTPException(status_code=404, detail="SoundFont không tìm thấy — hãy thêm soundfont vào Plugin Manager")
|
|
midi_events = [{
|
|
"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),
|
|
} for n in req.notes]
|
|
out_path = os.path.join(settings.PROCESSED_DIR, f"sf_{uuid.uuid4().hex[:10]}.wav")
|
|
try:
|
|
audio = render_soundfont_midi_to_audio(
|
|
midi_events, sf_path,
|
|
bank=req.bank, program=req.program,
|
|
sr=req.sample_rate, bpm=req.bpm,
|
|
)
|
|
from app.core import native_render
|
|
audio = native_render.normalize_audio_peak(audio)
|
|
sf.write(out_path, audio.T, req.sample_rate, subtype="FLOAT")
|
|
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(audio.shape[1] / float(req.sample_rate), 3),
|
|
"render_mode": "native-fluidsynth",
|
|
"soundfont": os.path.basename(sf_path),
|
|
}
|
|
except HTTPException:
|
|
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 soundfont thất bại: {e}")
|
|
|
|
|
|
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()
|
|
# OSC ready (mở thủ công / app spawn đã boot) HOẶC tiến trình app-spawn
|
|
# còn sống (đang boot) — preview chấp nhận gửi sớm một chút.
|
|
return _carla_osc_ready() or len(_CARLA_PROCESSES) > 0
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
@router.post("/render")
|
|
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)}")
|
|
|
|
# ── E1/E2: NATIVE HOST BRIDGE (daw_vst_bridge) ──────────────────────────────
|
|
# IPC dir giống _pick_dir_via_tauri_bridge: %APPDATA%/SonicForgeDAW/ipc — Rust
|
|
# (src-tauri) tạo, engine chỉ đọc/ghi file request/response.
|
|
BRIDGE_IPC_DIR = os.path.join(os.environ.get("APPDATA") or os.path.expanduser("~"), "SonicForgeDAW", "ipc")
|
|
|
|
def _bridge_ipc_dir() -> Optional[str]:
|
|
if os.path.isdir(BRIDGE_IPC_DIR):
|
|
return BRIDGE_IPC_DIR
|
|
return None
|
|
|
|
@router.get("/bridge/status")
|
|
async def bridge_status(current_user: dict = Depends(get_current_user)):
|
|
"""E1: trạng thái native bridge. Rust ghi bridge_status (JSON) khi spawn/
|
|
health-check; nếu chưa có → probe tail bridge.log (dòng started/exists)."""
|
|
ipc = _bridge_ipc_dir()
|
|
if not ipc:
|
|
return {"connected": False, "reason": "no_ipc_dir"}
|
|
st = os.path.join(ipc, "bridge_status")
|
|
if os.path.exists(st):
|
|
try:
|
|
with open(st, "r", encoding="utf-8") as fh:
|
|
raw = fh.read() or "{}"
|
|
try:
|
|
data = json.loads(raw)
|
|
except Exception:
|
|
data = {}
|
|
data.setdefault("connected", True)
|
|
return data
|
|
except Exception:
|
|
pass
|
|
log = os.path.normpath(os.path.join(ipc, "..", "logs", "bridge.log"))
|
|
if os.path.exists(log):
|
|
try:
|
|
with open(log, "r", encoding="utf-8", errors="ignore") as fh:
|
|
tail = fh.read().splitlines()[-5:]
|
|
joined = "\n".join(tail).lower()
|
|
return {"connected": ("started" in joined or "exists=true" in joined), "log_tail": tail}
|
|
except Exception:
|
|
pass
|
|
return {"connected": False, "reason": "no_status_no_log"}
|
|
|
|
class BridgeLoadRequest(BaseModel):
|
|
name: str # tên asset hiển thị (sf_xxx / Vital.vst3 / ...)
|
|
path: Optional[str] = None # path tuyệt đối nếu đã biết
|
|
instrumentType: str = "SF2" # 'VST3'|'VST2'|'SF2'|'SF3'|'SFZ'
|
|
channel: Optional[int] = None
|
|
|
|
def _resolve_bridge_asset(name: str, instrument_type: str, path: Optional[str]) -> Optional[str]:
|
|
if path and os.path.exists(path):
|
|
return path
|
|
base = os.path.basename((path or name).replace("\\", "/"))
|
|
base_noext = os.path.splitext(base)[0].lower()
|
|
# sf2/sf3/sfz: storage/soundfonts + system + user plugin_dirs
|
|
sf_dirs = [UPLOAD_SF_DIR, SYSTEM_SF_DIR]
|
|
try:
|
|
from app.core.vst_engine import _load_user_plugin_dirs
|
|
sf_dirs += _load_user_plugin_dirs() or []
|
|
except Exception:
|
|
pass
|
|
for base_dir in dict.fromkeys(d for d in sf_dirs if d):
|
|
if not os.path.isdir(base_dir):
|
|
continue
|
|
for root, _, files in os.walk(base_dir):
|
|
for fname in files:
|
|
if fname.lower().endswith((".sf2", ".sf3", ".sfz")) and \
|
|
(fname.lower() == base.lower() or os.path.splitext(fname)[0].lower() == base_noext):
|
|
return os.path.join(root, fname)
|
|
ext = (".vst3" if instrument_type.upper() == "VST3" else
|
|
(".dll", ".so") if instrument_type.upper() == "VST2" else
|
|
".sfz" if instrument_type.upper() == "SFZ" else None)
|
|
if ext:
|
|
try:
|
|
from app.core.vst_engine import _load_user_plugin_dirs
|
|
dirs = _load_user_plugin_dirs() or []
|
|
except Exception:
|
|
dirs = []
|
|
# Luon gop VST_DIR/SOUNDFONT_DIR: neu chi dung user plugin_dirs,
|
|
# VST trong C:/Program Files/Common Files/VST3 khong resolve
|
|
# duoc -> bridge load fail -> VSTi cam / khong mo GUI.
|
|
for d in (settings.VST_DIR, settings.SOUNDFONT_DIR):
|
|
if d and d not in dirs:
|
|
dirs.append(d)
|
|
for base_dir in dirs:
|
|
if not os.path.isdir(base_dir):
|
|
continue
|
|
for root, _, files in os.walk(base_dir):
|
|
for fn in files:
|
|
if (fn.lower().endswith(ext) if isinstance(ext, tuple) else fn.lower().endswith(ext)) and \
|
|
(fn.lower() == base.lower() or os.path.splitext(fn)[0].lower() == base_noext):
|
|
return os.path.join(root, fn)
|
|
# V9 bug 8: Windows VST3 = FOLDER "X.vst3" (chua X.vst3.dll) —
|
|
# khong la file nen file-walk tren khong thay -> resolve null ->
|
|
# bridge load fail -> VSTi khong auto-load khi mo project. Scan
|
|
# sub-folder trung ten: "x.vst3" == base.lower() hoac "x" ==
|
|
# base_noext.
|
|
for sub in os.listdir(root):
|
|
sp = os.path.join(root, sub)
|
|
if os.path.isdir(sp) and (sub.lower().endswith(ext) if isinstance(ext, tuple) else sub.lower().endswith(ext)) and \
|
|
(sub.lower() == base.lower() or os.path.splitext(sub)[0].lower() == base_noext):
|
|
return sp
|
|
return None
|
|
|
|
@router.post("/bridge/load")
|
|
async def bridge_load(req: BridgeLoadRequest, current_user: dict = Depends(get_current_user)):
|
|
"""E2: resolve path thật của asset rồi (a) ghi bridge_load.request cho Rust
|
|
watcher (nếu có) và (b) trả path để JS gọi invoke load_native_instrument
|
|
trực tiếp — pipeline hoạt động ngay, không chờ watcher."""
|
|
resolved = _resolve_bridge_asset(req.name, req.instrumentType, req.path)
|
|
if not resolved:
|
|
raise HTTPException(status_code=404, detail=f"Không tìm thấy asset: {req.name}")
|
|
wrote_request = False
|
|
ipc = _bridge_ipc_dir()
|
|
if ipc:
|
|
try:
|
|
req_file = os.path.join(ipc, "bridge_load.request")
|
|
if os.path.exists(req_file):
|
|
os.remove(req_file)
|
|
with open(req_file, "w", encoding="utf-8") as fh:
|
|
json.dump({"path": resolved, "type": req.instrumentType, "channel": req.channel}, fh)
|
|
wrote_request = True
|
|
except Exception:
|
|
pass
|
|
return {"success": True, "path": resolved, "type": req.instrumentType, "ipc_request_written": wrote_request}
|
|
|
|
@router.get("/bridge/log")
|
|
async def bridge_log(lines: int = Query(100, ge=1, le=2000), current_user: dict = Depends(get_current_user)):
|
|
"""E5: tail bridge.log cho UI debug. Log Rust ghi tại %APPDATA%/SonicForgeDAW/logs/bridge.log."""
|
|
log = os.path.normpath(os.path.join(BRIDGE_IPC_DIR, "..", "logs", "bridge.log"))
|
|
if not os.path.exists(log):
|
|
return {"lines": [], "path": log, "exists": False}
|
|
try:
|
|
with open(log, "r", encoding="utf-8", errors="ignore") as fh:
|
|
all_lines = fh.read().splitlines()
|
|
return {"lines": all_lines[-lines:], "path": log, "exists": True}
|
|
except Exception as e:
|
|
return {"lines": [], "path": log, "exists": False, "error": str(e)}
|
|
|
|
# ── E6: LAN/standalone fallback — mở GUI VSTi qua API (không cần Tauri) ─────
|
|
# Browser client (LAN) không có window.__TAURI__ → NativeBridgeService chỉ log,
|
|
# không invoke được open_vst_gui. Engine đẩy LOAD_INSTRUMENT + OPEN_GUI thẳng
|
|
# vào SHM của daw_vst_bridge (layout SharedMemoryIPC.h: controlQueue @2836,
|
|
# stride 1040, controlQueueCount @11156 — offsets verified bằng ctypes test).
|
|
# GUI hiện trên desktop máy chạy server.
|
|
_IT_ENUM = {"VST3": 0, "VST2": 1, "SF2": 2, "SF3": 2, "SFZ": 3}
|
|
|
|
class BridgeGuiRequest(BaseModel):
|
|
name: str # plugin id / tên asset
|
|
path: Optional[str] = None # path tuyệt đối nếu đã biết
|
|
instrumentType: Optional[str] = None # VST3|VST2|SF2|SF3|SFZ; None → tự suy
|
|
channel: int = 0 # MIDI channel bridge assign/open
|
|
|
|
def _resolve_bridge_asset_any(name: str, path: Optional[str], hint: Optional[str] = None):
|
|
"""Thử resolve asset qua từng loại instrument (hint trước, rồi VST3→VST2→
|
|
SF…). Client LAN không chắc type — backend tự tìm, tránh 404 nhầm (VST2
|
|
.dll mà client đoán VST3). Trả (path, instrument_type suy từ extension)."""
|
|
order = []
|
|
if hint and hint.upper() in _IT_ENUM:
|
|
order.append(hint.upper())
|
|
for t in order + [x for x in ("VST3", "VST2", "SF2", "SF3", "SFZ") if x not in order]:
|
|
p = _resolve_bridge_asset(name, t, path)
|
|
if p:
|
|
low = p.lower()
|
|
if low.endswith(".dll"):
|
|
t = "VST2"
|
|
elif low.endswith(".sfz"):
|
|
t = "SFZ"
|
|
elif low.endswith(".sf2") or low.endswith(".sf3"):
|
|
t = "SF2"
|
|
else:
|
|
t = "VST3"
|
|
return p, t
|
|
return None, None
|
|
|
|
def _running_sonicforge_pids() -> list:
|
|
pids = []
|
|
try:
|
|
out = subprocess.run(
|
|
["powershell", "-NoProfile", "-Command",
|
|
"Get-Process sonicforge-daw,daw_engine -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id"],
|
|
capture_output=True, text=True, timeout=10).stdout
|
|
for line in out.splitlines():
|
|
line = line.strip()
|
|
if line.isdigit():
|
|
pids.append(int(line))
|
|
except Exception:
|
|
pass
|
|
return pids
|
|
|
|
def _shm_exists(name: str) -> bool:
|
|
import ctypes, ctypes.wintypes as w
|
|
k = ctypes.windll.kernel32
|
|
k.OpenFileMappingA.argtypes = [w.DWORD, w.BOOL, ctypes.c_char_p]
|
|
k.OpenFileMappingA.restype = ctypes.c_void_p
|
|
h = k.OpenFileMappingA(0xF001F, False, name.encode())
|
|
if h:
|
|
k.CloseHandle(h)
|
|
return True
|
|
return False
|
|
|
|
def _shm_push_gui(shm_name: str, path: str, enum: int, plugin_id: str, channel: int):
|
|
"""Push LOAD_INSTRUMENT (type=2) + OPEN_GUI (type=4) vào SHM bridge.
|
|
C++ xử lý async — LOAD xong mới fulfil OPEN_GUI (pending-GUI sweep)."""
|
|
import ctypes, ctypes.wintypes as w, struct
|
|
k = ctypes.windll.kernel32
|
|
k.OpenFileMappingA.argtypes = [w.DWORD, w.BOOL, ctypes.c_char_p]
|
|
k.OpenFileMappingA.restype = ctypes.c_void_p
|
|
k.MapViewOfFile.argtypes = [ctypes.c_void_p, w.DWORD, w.DWORD, w.DWORD, ctypes.c_size_t]
|
|
k.MapViewOfFile.restype = ctypes.c_void_p
|
|
k.UnmapViewOfFile.argtypes = [ctypes.c_void_p]
|
|
k.CloseHandle.argtypes = [ctypes.c_void_p]
|
|
h = k.OpenFileMappingA(0xF001F, False, shm_name.encode())
|
|
if not h:
|
|
return False, "open shm fail"
|
|
ptr = k.MapViewOfFile(h, 0xF001F, 0, 0, 0)
|
|
if not ptr:
|
|
k.CloseHandle(h)
|
|
return False, "map shm fail"
|
|
try:
|
|
for i in range(8): # zero queue trước — tránh slot cũ lọt vào count mới
|
|
ctypes.memmove(ptr + 2836 + i * 1040, b"\x00" * 1040, 1040)
|
|
# LOAD_INSTRUMENT: type=2, arg0=InstrumentType, arg1=0 (Rust bỏ qua), channel, arg2=path
|
|
b = path.encode("utf-8")[:1023] + b"\x00"
|
|
ctypes.memmove(ptr + 2836, struct.pack("IIII", 2, enum, 0, channel), 16)
|
|
ctypes.memmove(ptr + 2836 + 16, b, len(b))
|
|
# OPEN_GUI: type=4, arg0=0, arg1=0 (hwnd=0 → bridge tự tạo native window), channel, arg2=plugin id
|
|
pb = plugin_id.encode("utf-8")[:1023] + b"\x00"
|
|
off = 2836 + 1040
|
|
ctypes.memmove(ptr + off, struct.pack("IIII", 4, 0, 0, channel), 16)
|
|
ctypes.memmove(ptr + off + 16, pb, len(pb))
|
|
ctypes.memmove(ptr + 11156, struct.pack("I", 2), 4) # controlQueueCount = 2
|
|
return True, None
|
|
finally:
|
|
k.UnmapViewOfFile(ptr)
|
|
k.CloseHandle(h)
|
|
|
|
@router.post("/bridge/gui")
|
|
async def bridge_gui(req: BridgeGuiRequest, current_user: dict = Depends(get_current_user)):
|
|
"""E6: mở native GUI VSTi trên máy chạy server — fallback cho browser LAN
|
|
không có Tauri. Pipeline giống invoke open_vst_gui: LOAD_INSTRUMENT rồi
|
|
OPEN_GUI qua SHM daw_vst_bridge."""
|
|
resolved, itype = _resolve_bridge_asset_any(req.name, req.path, req.instrumentType)
|
|
if not resolved:
|
|
raise HTTPException(status_code=404, detail=f"Không tìm thấy asset: {req.name}")
|
|
enum = _IT_ENUM.get(itype.upper(), 0)
|
|
shm_name = None
|
|
ipc = _bridge_ipc_dir()
|
|
if ipc:
|
|
st = os.path.join(ipc, "bridge_status")
|
|
if os.path.exists(st):
|
|
try:
|
|
with open(st, "r", encoding="utf-8") as fh:
|
|
data = json.loads(fh.read() or "{}")
|
|
shm_name = data.get("shm_name")
|
|
except Exception:
|
|
pass
|
|
if not shm_name:
|
|
for pid in _running_sonicforge_pids():
|
|
cand = f"SonicForge_DAW_IPC_{pid}"
|
|
if _shm_exists(cand):
|
|
shm_name = cand
|
|
break
|
|
if not shm_name:
|
|
raise HTTPException(status_code=503,
|
|
detail="Bridge chưa chạy (không tìm thấy SHM) — hãy mở app desktop trên máy chạy server trước")
|
|
ch = req.channel & 0xF
|
|
ok, err = _shm_push_gui(shm_name, resolved, enum, req.name, ch)
|
|
if not ok:
|
|
raise HTTPException(status_code=503, detail=f"Không ghi được SHM {shm_name}: {err}")
|
|
return {"success": True, "path": resolved, "type": itype, "enum": enum,
|
|
"channel": ch, "shm": shm_name}
|