import os, sys, uuid, json, tempfile, subprocess, time as _time, threading, asyncio, logging, queue 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, _is_vst2_dll 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() unknown_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 "")) fx_norm_names.add(_norm_path_stem(e.get("path") or "")) elif e.get("is_unknown"): unknown_paths.add(path) unknown_norm_names.add(_norm_plugin_name(e.get("name") or "")) unknown_norm_names.add(_norm_path_stem(e.get("path") or "")) 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ữ. # Ngoài is_fx còn loại entry UNKNOWN theo TÊN: bridge timeout khi # introspect VST3 (vd Ozone Pro.vst3, Tonal Balance Control Pro.vst3 — # module treo 12s → skip) nên không vào fx_norm_names, nhưng VST2 legacy # .dll CÙNG TÊN (Ozone Pro.dll) vẫn lọt vào nút Synth → load FX như # instrument → không ra tiếng. UNKNOWN = không xác định loại → cũng # không hiện là instrument (cùng chính sách block theo path ở trên). 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 and not unknown_norm_names: return False nm_name = _norm_plugin_name(name) nm_stem = _norm_path_stem(v.get("path") or "") return (nm_name in fx_norm_names or nm_stem in fx_norm_names or nm_name in unknown_norm_names or nm_stem in unknown_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 _norm_path_stem(path: str) -> str: """Chuẩn hóa TÊN FILE (không phần mở rộng) của path — dùng cho entry UNKNOWN mà bridge chỉ report filename kèm đuôi ('Ozone Pro.vst3') vì timeout khi introspect: stem 'Ozone Pro' khớp VST2 legacy 'Ozone Pro.dll' cùng tên.""" base = os.path.basename((path or "").replace("\\", "/")) s = "".join(ch for ch in os.path.splitext(base)[0].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 and _is_vst2_dll(os.path.join(root, f))): 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) # Drain stdout NGAY TU DAU (daemon thread) và log TẤT CẢ các dòng # bridge in — gồm cả các dòng in TRƯỚC SF_FXGUI_PORT= ("[FxGui] # env ..." của RenderFxJob.cpp) mà cách đọc-tới-port cũ nuốt mất, # làm log engine không chứng minh được binary/env nào đang chạy. # Port được chuyển về qua queue (thread này không đọc stdout). port_q = queue.Queue(maxsize=1) def _drain_fx_gui_stdout(pp): try: for ln in pp.stdout: ln = (ln or "").strip() if ln: logger.info("[fx-gui] %s", ln) if ln.startswith("SF_FXGUI_PORT="): try: port_q.put_nowait(int(ln.split("=", 1)[1])) except Exception: pass except Exception: pass threading.Thread(target=_drain_fx_gui_stdout, args=(proc,), daemon=True).start() embed_url = None try: port = port_q.get(timeout=60) embed_url = f"http://127.0.0.1:{port}" except Exception: pass if not embed_url: proc.terminate() try: proc.wait(timeout=5) except Exception: proc.kill() raise HTTPException(status_code=500, detail="Bridge không báo port fx-gui (mở GUI thất bại).") proc._sf_plugin_path = plugin_path proc._sf_embed_url = embed_url _register_fx_gui_process(proc) return {"success": True, "started": True, "already_running": False, "embed_url": embed_url, "cmd": cmd} proc = subprocess.Popen(cmd, close_fds=os.name != "nt", 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}