import os, sys, uuid, json, tempfile, subprocess, time as _time, threading, asyncio, logging
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.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, _is_loopback
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": "
"} 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):
from app.core.render_engine import _find_sf2_path, _find_default_sf2
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")
# Not found in the 3 standard dirs -> resolve like offline render
# (render_engine._find_sf2_path): scan user plugin_dirs (Downloads...),
# then fall back to default GM font. UI preview must hear the SAME
# instrument as offline; before, 404 -> soundfontPlayer.js played a
# fallback oscillator (synth sound).
resolved = _find_sf2_path(clean_id)
if not resolved or not os.path.exists(resolved):
resolved = _find_default_sf2()
if resolved and os.path.exists(resolved):
return FileResponse(resolved, media_type="application/octet-stream", filename=os.path.basename(resolved))
raise HTTPException(status_code=404, detail="SoundFont asset not found")
class RenderRequest(BaseModel):
project_json: dict
output_filename: Optional[str] = "render_output.wav"
# D3 export options: bit-depth 16|24|32 (32 = float), SR 44100|48000|96000,
# dither TPDF+noise shaping (PCM only), normalize target -1dBTP|-14LUFS,
# mono-compat check (side/mid ratio).
bit_depth: Optional[int] = 32
sample_rate: Optional[int] = 0
dither: Optional[bool] = False
normalize: Optional[bool] = False
normalize_target: Optional[str] = "-1dbtp"
mono_check: Optional[bool] = False
# Gap 13: BWF/INFO metadata (title/artist/isrc/comment/description/...)
metadata: Optional[dict] = None
class FxGuiRequest(BaseModel):
"""Mở native GUI của VST FX qua bridge (Windows desktop)."""
path: str
name: Optional[str] = None
# embed=True → bridge --fx-gui (off-screen + HTTP frame/input server,
# PLAN_MASTERBUS_VST_GUI_EMBED.md); embed=False → cửa sổ rời cũ.
embed: bool = False
# 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 = ""
# preset_b64: preset GUI capture mới nhất (bridge state blob hoặc raw
# .vstpreset b64) — bridge áp sau fx->load() để mở GUI với ĐÚNG cài đặt
# đã chỉnh (trước đây GUI mở lại load default → mất settings khi
# hide/show).
preset_b64: 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 ` (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
if req.preset_b64:
job["preset_b64"] = req.preset_b64
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:
# fx-gui spawn o BELOW_NORMAL: process nay load instance VST THU HAI
# (CPU spike vai giay) - neu o NORMAL se preempt realtime audio loop
# (fx_vst_bridge --realtime-fx, thread NORMAL) -> underrun -> am "bop
# nghet" ngay luc mo GUI roi moi hoi phuc. BELOW_NORMAL cho load GUI
# khong canh tranh thread audio.
_gui_creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
if os.name == "nt":
_gui_creationflags |= getattr(subprocess, "BELOW_NORMAL_PRIORITY_CLASS", 0)
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=_gui_creationflags)
# Bridge prints SF_FXGUI_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=_gui_creationflags)
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."""
import soundfile as sf
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 = ""
class FxRealtimeSetChainRequest(BaseModel):
session_id: str = ""
fx_chain: list = []
@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.post("/fx-realtime/set-chain")
async def fx_realtime_set_chain(req: FxRealtimeSetChainRequest,
current_user: dict = Depends(get_current_user)):
"""Cập nhật chain trên session realtime ĐANG CHẠY — KHÔNG restart session.
Engine ghi lại job file (seq++); bridge poll seq → RealtimeFxChain::setChain
(worker load chain mới, swap atomic dưới mutex — audio thread copy
shared_ptr, không dừng) → load/đổi VST FX + đổi preset GUI không ngắt âm/
crackle. Session/WS/SHM giữ nguyên."""
enforce_password_changed(current_user)
sess = fx_realtime.get_session(req.session_id or "")
if not sess:
raise HTTPException(status_code=404, detail="Session realtime không tồn tại")
ok = sess.set_chain(req.fx_chain or [])
return {"success": True, "updated": 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. Standalone (loopback) không cần token — auto admin
# (đồng bộ get_current_user). Client LAN thiếu token → close.
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
else:
_host = websocket.client.host if websocket.client else None
if not _is_loopback(_host):
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():
# Gửi ASAP (không pace theo realtime): bridge sản xuất burst theo batch
# 4 → gửi hết ngay → CLIENT tích lũy buffer (worklet FILL) hấp thụ
# jitter bridge/WS. Pace đúng realtime trước đây làm client queue ≈1 →
# bất kỳ jitter (bridge process VST biến thiên, GC, WS) → queue cạn →
# zero-fill crackle. Worklet cap 48 block chặn overflow khi bridge
# nhanh hơn.
# UNDERRUN do stall (main-thread browser block GUI/rAF/React) xử lí Ở
# CLIENT: worklet lặp block cuối (HOLD) rồi fade-out ~21ms
# (sf-fx-realtime.js). Engine KHÔNG fade — fade gửi từ engine không thể
# tới worklet kịp khi main-thread stall chặn cả 2 hướng (fade nằm trong
# browser queue, tới sau khi worklet đã cạn buffer → click vẫn còn), và
# fade-in sau stall tạo dip âm lượng định kỳ = vibrato/wow&flutter.
# V34 diag: pump gap log - bridge khong san xuat block (SHM rong) > 30ms
# => fxrt_pump.log (phan biet bridge stall vs Python pump stall).
_plog = os.environ.get("FXRT_PUMP_LOG")
if not _plog:
_ad = os.environ.get("APPDATA")
_plog = os.path.join(_ad, "SonicForgeDAW", "logs", "fxrt_pump.log") if _ad else ""
_pf = open(_plog, "a", buffering=1) if _plog else None
_pump_last = None
if _pf is not None:
_pf.write("%d pump_start\n" % int(_time.time() * 1000))
_last_lat = None
_last_proc = None
try:
while True:
try:
sess.flush_input_stale() # input lẻ kẹt trong pending → publish
_lats = sess.get_latencies()
_total = sum(_lats.values()) if _lats else 0
# G3: processed samples (playhead engine-derived) — bridge
# bao moi block; gui kem latency message khi doi (khong gate
# theo total nua — total chi doi khi chain doi, processed
# doi lien tuc). App: position = (processed - anchor - lat)/sr.
_proc = sess.get_processed()
_proc_changed = (_proc is not None and _proc != _last_proc)
if _total != _last_lat or _proc_changed:
_last_lat = _total
_last_proc = _proc
await websocket.send_text(json.dumps({
"cmd": "latency",
"total": _total,
"latencies": _lats,
"processed": _proc,
"stats": sess.stats,
}))
except Exception:
pass
blks = sess.read_output()
if not blks:
# to_thread(time.sleep): asyncio.sleep trên Windows bị cap
# 15.6ms (GQCS/IOCP, Win11 cap nền) dù timeBeginPeriod;
# time.sleep dùng high-res waitable timer -> poll đúng 2ms.
await asyncio.to_thread(_time.sleep, 0.002)
continue
if _pf is not None:
_now = _time.time()
if _pump_last is not None:
_gap = (_now - _pump_last) * 1000.0
if _gap > 30.0:
_pf.write("%d pump_gap %.0f n=%d\n" % (int(_now * 1000), _gap, len(blks)))
_pump_last = _now
for _blk in blks:
await websocket.send_bytes(_blk)
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"))
elif cmd.get("cmd") == "flush":
# V24: client resume sau stall — xóa backlog cũ (lag ~1s).
sess.flush_all()
await websocket.send_text(json.dumps({"cmd": "flush_done"}))
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."""
from app.core.render_engine import _find_sf2_path, _find_default_sf2
import soundfile as sf
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)
from app.core.render_engine import PythonRenderEngine
import soundfile as sf
import numpy as np
engine = PythonRenderEngine()
# Prevent path traversal: strip directory components; force .wav/.ogg.
safe_name = os.path.basename((req.output_filename or "render_output.wav").replace("\\", "/"))
if not (safe_name.lower().endswith(".wav") or safe_name.lower().endswith(".ogg")):
safe_name += ".wav"
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
try:
result_path = engine.render_project(
req.project_json, output_path,
bit_depth=req.bit_depth or 16,
out_sample_rate=req.sample_rate or 0,
dither=bool(req.dither),
normalize=bool(req.normalize),
normalize_target=req.normalize_target or "-1dbtp",
metadata=req.metadata or None,
)
# 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
# D3: mono-compat — side/mid RMS ratio, cảnh báo khi sum mono mất nhiều.
if req.mono_check and data.shape[1] >= 2:
_ratio = loudness.mono_side_ratio(data.T)
resp["mono_side_ratio"] = round(_ratio, 3)
if _ratio > 0.85:
resp["mono_warning"] = ("Side/Mid %.2f — mono sum mất gần hết "
"âm thanh, kiểm tra phase!" % _ratio)
elif _ratio > 0.5:
resp["mono_warning"] = ("Side/Mid %.2f — âm thanh phát mono "
"yếu rõ, kiểm tra phase" % _ratio)
else:
resp["mono_warning"] = 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}
|