1076 lines
45 KiB
Python
1076 lines
45 KiB
Python
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": "<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 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 <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)
|
|
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 <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)."""
|
|
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)}")
|