1472 lines
66 KiB
Python
1472 lines
66 KiB
Python
import os, sys, uuid, json, tempfile, subprocess, time as _time, threading, asyncio, logging
|
|
import numpy as np
|
|
import soundfile as sf
|
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, 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()
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
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
|
|
# User ĐÃ lưu plugin_dirs (kể cả [] — xóa hết dir) → dùng ĐÚNG danh sách
|
|
# đó, KHÔNG fallback default: nếu fallback, remove hết dir rồi re-scan
|
|
# vẫn còn VST trong thư mục mặc định (vd C:\Program Files\Common Files\
|
|
# VST3 → iZotope Ozone Pro load fail 0x7E vẫn hiện trong MASTERING PANEL).
|
|
if "plugin_dirs" in user:
|
|
return {
|
|
"plugin_dirs": plugin_dirs,
|
|
"vst_dir": vst_dir,
|
|
"soundfont_dir": soundfont_dir,
|
|
"plugin_dirs_user_set": True,
|
|
}
|
|
# Chưa từng lưu → backward compat (file cũ lưu vst_dir/soundfont_dir
|
|
# riêng) → gộp vào list; không có gì → fallback default env.
|
|
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": False,
|
|
}
|
|
|
|
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"], user_dirs_override=d.get("plugin_dirs_user_set", False))
|
|
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()
|
|
fx_norm_names = 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)
|
|
fx_norm_names.add(_norm_plugin_name(e.get("name") or ""))
|
|
elif e.get("is_unknown"):
|
|
unknown_paths.add(path)
|
|
except Exception:
|
|
pass
|
|
blocked = fx_paths | unknown_paths
|
|
|
|
def _is_fx_dupe(v) -> bool:
|
|
# iZotope cài VST2 legacy vào Steinberg\VSTPlugins\iZotope\ (iZOzonePro.dll,
|
|
# iZNeutronProCompressor.dll...) TRÙNG với VST3 FX đã scan (Ozone Pro Dynamic
|
|
# EQ.vst3...) nhưng path .dll ≠ path .vst3 → block theo path KHÔNG trúng.
|
|
# Loại theo TÊN normalized (bỏ prefix iZ, lowercase alnum) — VST2 thật
|
|
# (DSK Asian DreamZ, Kong Audio...) không trùng tên FX → vẫn giữ.
|
|
name = v.get("name") or ""
|
|
if name.startswith("iZ") and "izotope" in ((v.get("path") or "").lower()):
|
|
# iZotope KHÔNG sản xuất VST instrument — mọi module .dll/VST2 của
|
|
# hãng (Ozone/Neutron/Nectar/RX/Tonal Balance...) đều là FX/processor.
|
|
# VST2 legacy không introspect được → bridge không đánh is_fx → lọt
|
|
# vào nút Synth (iZOzonePro, iZRXProMusicRebalance, iZTonalBalanceControlPro).
|
|
return True
|
|
if not fx_norm_names:
|
|
return False
|
|
return _norm_plugin_name(name) in fx_norm_names
|
|
|
|
if blocked:
|
|
avail["vst_instruments"] = [v for v in avail["vst_instruments"]
|
|
if (v.get("path") or "").lower() not in blocked
|
|
and not _is_fx_dupe(v)]
|
|
# 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
|
|
and not _is_fx_dupe(v)]
|
|
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 _norm_plugin_name(name: str) -> str:
|
|
"""Tên plugin chuẩn hóa để so khớp FX trùng (iZotope VST2 legacy vs VST3):
|
|
bỏ prefix 'iZ' (iZOzonePro → OzonePro), lowercase, chỉ giữ alnum
|
|
(Ozone Pro Dynamic EQ → ozoneprodynamiceq)."""
|
|
s = "".join(ch for ch in (name or "").lower() if ch.isalnum())
|
|
if s.startswith("iz") and len(s) > 2:
|
|
s = s[2:]
|
|
return s
|
|
|
|
|
|
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,
|
|
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
)
|
|
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 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
|
|
# shm: tên SHM session realtime (SonicForge_FXRT_*) — bridge fx-gui
|
|
# mở mapping này để PEEK input ring và chạy processAudio() trên instance
|
|
# GUI → meter trong plugin nhảy theo âm thật (GUI không consume ring).
|
|
shm: str = ""
|
|
|
|
|
|
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)
|
|
|
|
|
|
@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)}
|
|
if req.shm:
|
|
job["shm"] = req.shm
|
|
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",
|
|
env=dict(os.environ, SF_PARENT_PID=str(os.getpid())),
|
|
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).")
|
|
# Keep draining stdout in a daemon thread: the bridge logs to
|
|
# stdout/stderr while running; if the pipe fills (64KB) the bridge
|
|
# blocks on any later write -> GUI freezes/breaks.
|
|
def _drain_fx_gui_stdout(pp):
|
|
try:
|
|
for ln in pp.stdout:
|
|
ln = (ln or "").strip()
|
|
if ln:
|
|
logger.info("[fx-gui] %s", ln)
|
|
except Exception:
|
|
pass
|
|
threading.Thread(target=_drain_fx_gui_stdout, args=(proc,), daemon=True).start()
|
|
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",
|
|
env=dict(os.environ, SF_PARENT_PID=str(os.getpid())),
|
|
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]
|
|
|
|
|
|
|
|
@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 preset → pedalboard: preset chỉnh trong
|
|
native GUI (.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 hệ thống (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 chỉnh âm trong native GUI (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:
|
|
msg = await websocket.receive()
|
|
if msg.get("type") == "websocket.disconnect" or not sess.alive():
|
|
break
|
|
if msg.get("text") is not None:
|
|
# Control protocol Phase 1: SET_PARAM / REPORT_LATENCY qua WS text.
|
|
try:
|
|
cmd = json.loads(msg["text"])
|
|
except Exception:
|
|
continue
|
|
if cmd.get("cmd") == "set_param":
|
|
sess.set_param(cmd.get("slot"), cmd.get("key"), cmd.get("value"))
|
|
elif cmd.get("cmd") == "latency":
|
|
sess.report_latency(cmd.get("slot"), cmd.get("samples"))
|
|
continue
|
|
data = msg.get("bytes")
|
|
if data is None:
|
|
continue
|
|
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}")
|
|
|
|
|
|
@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)
|
|
# EBU R128 loudness + true peak (D1) — trả kèm response để mastering
|
|
# panel hiển thị LUFS/dBTP ngay sau export mà không cần đọc lại file.
|
|
data, _sr = sf.read(result_path, dtype="float32", always_2d=True)
|
|
audio = data.T
|
|
from app.core import loudness
|
|
resp = {
|
|
"url": f"/static/audio/processed/{os.path.basename(result_path)}",
|
|
"path": result_path,
|
|
}
|
|
_lufs = loudness.integrated_lufs(audio, _sr)
|
|
_tp = loudness.true_peak_db(audio, _sr)
|
|
resp["lufs"] = round(_lufs, 2) if np.isfinite(_lufs) else None
|
|
resp["true_peak_db"] = round(_tp, 2) if np.isfinite(_tp) else None
|
|
return resp
|
|
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,
|
|
creationflags=subprocess.CREATE_NO_WINDOW).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}
|