Files
SonicForgeStudio/app/api/v1/plugins.py
T
3dtours cc8b286f6c feat: native folder picker (Explorer) + Synth liet ke VSTi da scan + fix VU leak (ban 1.1.4)
1) Folder picker dung WINDOW EXPLORER (khong prompt nhap tay):
   - src-tauri/src/lib.rs: IPC bridge — thread watcher ipc/pick_dir.request
     -> run_on_main_thread -> dialog().file().blocking_pick_folder()
     (tauri-plugin-dialog = IFileDialog/Explorer) -> ghi pick_dir.response;
     ghi marker tauri_bridge_ready luc setup.
   - app/api/v1/plugins.py: POST /pick-dir (def sync -> threadpool, khong
     auth) — uu tien Tauri bridge, fallback PowerShell FolderBrowserDialog
     (Win) / osascript (macOS) / zenity-kdialog (Linux).
   - app.jsx pickPluginFolder: 1) pickPluginDir (native) -> 2) __TAURI__
     invoke -> 3) in-app browser -> 4) prompt (cuoi cung).

2) Nut Synth liet ke VSTi da scan (truoc day rong):
   - Root: list_available() chi quet settings.VST_DIR (mac dinh
     /opt/daw_engine/vst3) trong khi Plugin Manager scan plugin_dirs user.
   - plugins.py list_plugins: gop _scan_vst_in_dirs(plugin_dirs) (file
     .vst3/.dll/.so + folder X.vst3 Windows).
   - vst_engine.py: PluginManager.extra_vst_dirs + _scan_plugins quet them
     (ca folder .vst3) + get_plugin_manager doc plugin_dirs.json -> load_vst
     tim thay plugin user scan khi render.

3) VU meter leak: section-tab play -> sang MAIN SESSION -> track MAIN van
   animate theo am section.
   - Root: VU tick fallback _sub_ (sub-node section co analyser) chay cho
     ca canvas MAIN; startSubTabPlayback ghi node o key track MAIN.
   - Fix: fallback _sub_ chi ap dung cho canvas SECTION (isSessVu); 2 trigger
     piano-roll them prefix _sess_ theo st.parent_tab_id.

Verify: 86 tests pass; engine frozen STARTUP 1.35s, 1 engine, 0 spawn '-c';
pick-dir IPC mock tra dung path; scan VST user dirs OK.
2026-08-09 13:05:53 +00:00

449 lines
19 KiB
Python

import os, sys, uuid, json, tempfile, subprocess, time as _time
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional, Any
from app.config import settings
from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH
from app.core.render_engine import PythonRenderEngine
from app.core.soundfont_inspector import SoundFontInspector
from app.core.soundfont_converter import SoundFontConverter
from app.core.soundfont_scanner import SoundFontAutoScanner
from app.api.v1.auth import get_current_user, enforce_password_changed
router = APIRouter()
UPLOAD_SF_DIR = os.path.join(settings.STORAGE_DIR, "soundfonts")
os.makedirs(UPLOAD_SF_DIR, exist_ok=True)
SYSTEM_SF_DIR = settings.SOUNDFONT_DIR
SYSTEM_VST_DIR = settings.VST_DIR
# User dirs (Windows/macOS — người dùng chọn qua folder picker trong
# Plugins Manager). File global (không per-user): desktop app 1 user.
PLUGIN_DIRS_FILE = os.path.join(settings.STORAGE_DIR, "plugin_dirs.json")
def _load_plugin_dirs() -> dict:
if os.path.exists(PLUGIN_DIRS_FILE):
try:
with open(PLUGIN_DIRS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def _save_plugin_dirs(dirs: dict):
os.makedirs(settings.STORAGE_DIR, exist_ok=True)
with open(PLUGIN_DIRS_FILE, "w", encoding="utf-8") as f:
json.dump(dirs, f, indent=2)
def _effective_dirs() -> dict:
"""Env/.env (Docker) là base; user dirs (file) override nếu khai báo.
plugin_dirs: list thư mục user thêm trong Plugins Manager (mỗi thư mục
có thể chứa cả VST lẫn SoundFont — scan tự phân loại). Nếu user chưa
khai báo → fallback env VST_DIR + SOUNDFONT_DIR.
"""
user = _load_plugin_dirs()
plugin_dirs = [d for d in (user.get("plugin_dirs") or []) if d]
vst_dir = settings.VST_DIR
soundfont_dir = settings.SOUNDFONT_DIR
# Backward compat: file cũ lưu vst_dir/soundfont_dir riêng → gộp vào list.
if not plugin_dirs:
if user.get("vst_dir"):
plugin_dirs.append(user["vst_dir"])
if user.get("soundfont_dir"):
plugin_dirs.append(user["soundfont_dir"])
if not plugin_dirs:
plugin_dirs = [vst_dir, soundfont_dir]
return {
"plugin_dirs": plugin_dirs,
"vst_dir": vst_dir,
"soundfont_dir": soundfont_dir,
"plugin_dirs_user_set": bool(user.get("plugin_dirs")),
}
class DirsRequest(BaseModel):
vst_dir: Optional[str] = None
soundfont_dir: Optional[str] = None
plugin_dirs: Optional[list] = None
@router.get("/dirs")
async def get_plugin_dirs():
return {"success": True, **(_effective_dirs())}
@router.post("/dirs")
async def save_plugin_dirs(req: DirsRequest, current_user: dict = Depends(get_current_user)):
enforce_password_changed(current_user)
user = _load_plugin_dirs()
if req.plugin_dirs is not None:
user["plugin_dirs"] = [d.strip() for d in req.plugin_dirs if d and d.strip()]
# Xóa field cũ (đã gộp vào plugin_dirs) tránh nhầm lẫn
user.pop("vst_dir", None)
user.pop("soundfont_dir", None)
else:
if req.vst_dir is not None:
user["vst_dir"] = req.vst_dir.strip()
if req.soundfont_dir is not None:
user["soundfont_dir"] = req.soundfont_dir.strip()
_save_plugin_dirs(user)
return {"success": True, **(_effective_dirs())}
@router.post("/scan")
async def scan_plugin_dirs(background_tasks: BackgroundTasks = None,
current_user: dict = Depends(get_current_user)):
"""Scan các dir hiệu lực (env + user override): cập nhật catalog
soundfont (inspector/scanner) + liệt kê VST. Trả về danh sách riêng rẽ
VST (vst_found) + SoundFont (soundfonts) theo từng thư mục user khai báo."""
enforce_password_changed(current_user)
dirs = _effective_dirs()
plugin_dirs = dirs["plugin_dirs"]
# SoundFont: quét + inspect vào catalog (scan_once dùng dir hiệu lực)
scanner = SoundFontAutoScanner(system_sf_dirs=plugin_dirs, upload_sf_dir=UPLOAD_SF_DIR)
if background_tasks:
background_tasks.add_task(scanner.scan_once)
else:
scanner.scan_once()
catalog = scanner.get_catalog()
# VST + SoundFont: walk từng thư mục, phân loại riêng rẽ theo extension
vst_found = []
sf_found = []
for d in plugin_dirs:
if not os.path.isdir(d):
continue
for root, _dirs, files in os.walk(d):
for f in files:
low = f.lower()
if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
vst_found.append({"name": os.path.splitext(f)[0],
"path": os.path.join(root, f),
"dir": d,
"type": "VST3" if low.endswith(".vst3") else "VST2"})
elif low.endswith(".sf2") or low.endswith(".sf3"):
sf_found.append({"name": os.path.splitext(f)[0],
"path": os.path.join(root, f),
"dir": d})
return {
"success": True,
"plugin_dirs": plugin_dirs,
"vst_found": vst_found,
"vst_count": len(vst_found),
"soundfonts": sf_found,
"soundfont_count": len(sf_found),
}
_inspector = None
_scanner = None
def get_inspector():
global _inspector
if _inspector is None:
d = _effective_dirs()
_inspector = SoundFontInspector(d["plugin_dirs"][0] if d["plugin_dirs"] else settings.SOUNDFONT_DIR,
upload_sf_dir=UPLOAD_SF_DIR)
return _inspector
def get_scanner():
global _scanner
if _scanner is None:
d = _effective_dirs()
_scanner = SoundFontAutoScanner(system_sf_dirs=d["plugin_dirs"], upload_sf_dir=UPLOAD_SF_DIR)
_scanner.scan_once()
return _scanner
@router.get("/available")
async def list_plugins(current_user: dict = Depends(get_current_user)):
d = _effective_dirs()
pm = PluginManager(vst_dir=d["vst_dir"], sf_dir=d["soundfont_dir"], upload_sf_dir=UPLOAD_SF_DIR)
avail = pm.list_available()
# Gộp VST từ plugin_dirs user đã scan — list_available() CHỈ quét vst_dir
# env (mặc định /opt/daw_engine/vst3) → Synth dropdown không thấy VSTi mà
# Plugin Manager đã scan trong thư mục user chọn (bug: nút Synth rỗng).
extra = _scan_vst_in_dirs(d["plugin_dirs"])
by_id = {v["id"]: v for v in avail["vst_instruments"]}
for v in extra:
by_id.setdefault(v["id"], v)
avail["vst_instruments"] = list(by_id.values())
return avail
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 (VST3 folder Windows = .vst3.dll bên trong)."""
found = {}
for d in dirs:
if not d or not os.path.isdir(d):
continue
for root, _dirs, files in os.walk(d):
for f in files:
low = f.lower()
if low.endswith(".vst3") or low.endswith(".dll") or low.endswith(".so"):
name = os.path.splitext(f)[0]
if name not in found:
found[name] = {
"id": name, "name": name,
"type": "VST3" if low.endswith(".vst3") else "VST2",
"path": os.path.join(root, f),
}
return list(found.values())
# ── Native folder picker (user yêu cầu: dùng Windows Explorer, không phải
# nhập tay) ─────────────────────────────────────────────────────────────
PICK_DIR_TIMEOUT = 120 # user có thể mở dialog lâu
def _pick_dir_via_tauri_bridge() -> Optional[str]:
"""Tauri shell (Rust watcher trong lib.rs) mở NATIVE dialog (IFileDialog /
Explorer) qua file IPC: engine ghi pick_dir.request → Rust mở dialog →
ghi pick_dir.response. Trả None nếu bridge không tồn tại (chạy standalone)."""
root = os.environ.get("APPDATA") or os.path.expanduser("~")
ipc = os.path.join(root, "SonicForgeDAW", "ipc")
if not os.path.isdir(ipc):
return None
# Marker do Rust viết lúc setup — bridge chỉ có trong app Tauri desktop
if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")):
return None
req = os.path.join(ipc, "pick_dir.request")
resp = os.path.join(ipc, "pick_dir.response")
try:
for f in (req, resp):
if os.path.exists(f):
os.remove(f)
with open(req, "w", encoding="utf-8") as fh:
fh.write("1")
deadline = _time.time() + PICK_DIR_TIMEOUT
while _time.time() < deadline:
if os.path.exists(resp):
try:
with open(resp, "r", encoding="utf-8") as fh:
val = fh.read().strip()
finally:
os.remove(resp)
return val or None
_time.sleep(0.1)
except Exception:
pass
return None
def _pick_dir_native_engine() -> Optional[str]:
"""Fallback khi không có Tauri bridge: PowerShell FolderBrowserDialog
(Windows), osascript (macOS), zenity/kdialog (Linux)."""
if os.name == "nt":
ps = (
"Add-Type -AssemblyName System.Windows.Forms; "
"$f = New-Object System.Windows.Forms.FolderBrowserDialog; "
"$f.Description = 'Chọn thư mục chứa VST / SoundFont'; "
"if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }"
)
try:
r = subprocess.run(
["powershell", "-NoProfile", "-STA", "-Command", ps],
capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
)
return r.stdout.strip() or None
except Exception:
return None
if sys.platform == "darwin":
try:
r = subprocess.run(
["osascript", "-e",
'POSIX path of (choose folder with prompt "Chọn thư mục plugin")'],
capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT,
)
return r.stdout.strip() or None
except Exception:
return None
for cmd in (["zenity", "--file-selection", "--directory"],
["kdialog", "--getexistingdirectory", os.path.expanduser("~")]):
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=PICK_DIR_TIMEOUT)
if r.returncode == 0:
p = r.stdout.strip()
if p:
return p
except Exception:
continue
return None
@router.post("/pick-dir")
def pick_plugin_directory():
"""Mở NATIVE folder picker. Không cần auth (desktop local, chỉ mở dialog).
Trả {"path": "<thư mục>"} hoặc {"path": None} (hủy/không có dialog).
Định nghĩa def (sync) → FastAPI chạy trong threadpool — không block loop
trong lúc user chọn thư mục (có thể mất phút)."""
path = _pick_dir_via_tauri_bridge()
if path is None:
path = _pick_dir_native_engine()
return {"path": path}
@router.get("/default-soundfonts")
async def list_default_soundfonts():
static_sf_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "static", "soundfonts")
result = []
if os.path.isdir(static_sf_dir):
for f in os.listdir(static_sf_dir):
if f.endswith(".sf2") or f.endswith(".sf3"):
result.append({
"id": os.path.splitext(f)[0],
"name": f,
"file": f,
"url": f"/soundfonts/{f}"
})
return result
@router.get("/soundfonts/catalog")
async def soundfont_catalog(current_user: dict = Depends(get_current_user)):
scanner = get_scanner()
full_catalog = scanner.get_catalog()
inspector = get_inspector()
condensed_catalog = inspector.get_condensed_catalog_summary(full_catalog)
return {"full_catalog": full_catalog, "condensed_catalog": condensed_catalog}
@router.get("/soundfont-instruments/{sf_id}")
async def list_soundfont_instruments(sf_id: str, current_user: dict = Depends(get_current_user)):
pm = PluginManager(upload_sf_dir=UPLOAD_SF_DIR)
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"
@router.post("/render")
async def render_project(
req: RenderRequest,
current_user: dict = Depends(get_current_user)
):
enforce_password_changed(current_user)
engine = PythonRenderEngine()
# Prevent path traversal: strip any directory components and force .wav.
safe_name = os.path.basename((req.output_filename or "render_output.wav").replace("\\", "/"))
if not safe_name.lower().endswith(".wav"):
safe_name += ".wav"
output_path = os.path.join(settings.PROCESSED_DIR, safe_name)
try:
result_path = engine.render_project(req.project_json, output_path)
return {"url": f"/static/audio/processed/{os.path.basename(result_path)}", "path": result_path}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Render failed: {str(e)}")