M4: /fx-gui redirect vao session fxrt (native-window mode, embed_url=None) + watchdog auto-restart

This commit is contained in:
2026-09-04 11:37:08 +07:00
parent e6d01d90f1
commit 14fd794e6d
2 changed files with 216 additions and 0 deletions
+72
View File
@@ -711,6 +711,24 @@ async def open_fx_gui(req: FxGuiRequest, current_user: dict = Depends(get_curren
plugin_path = (req.path or "").strip()
if not plugin_path:
raise HTTPException(status_code=400, detail="Thiếu đường dẫn VST plugin.")
# ── Phase 2 (M4): plugin là slot của session FX realtime (mastering) → mở
# editor của CHÍNH instance DSP ngay trong fxrt_bridge (juce_fx_bridge) qua
# SHM ctrl ring ed.open — KHÔNG spawn fx_vst_bridge instance 2 + feeder
# (nguồn CPU 100%). GUI = cửa sổ native "FX slot N" trên desktop (không
# HTTP embed frame → embed_url=None, native=true — frontend M6 dùng mode
# này). Vẫn spawn fx_vst_bridge khi: không shm / session không tồn tại /
# plugin không phải slot chain (instrument, offline, fallback an toàn).
_sess = fx_realtime.get_session((req.shm or "").strip()) if (req.shm or "").strip() else None
if _sess is not None and _sess.alive():
_slot = _sess.chain_slot_for_path(plugin_path)
if _slot is not None:
_already = fx_realtime.editor_find(plugin_path)
_same = _already is not None and _already[0] == _sess.name
_sess.send_editor_cmd("open", slot=_slot)
fx_realtime.editor_register(plugin_path, _sess.name, _slot)
return {"success": True, "started": not _same,
"already_running": bool(_same), "native": True,
"embed_url": None, "session_id": _sess.name, "slot": _slot}
# 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:
@@ -808,6 +826,17 @@ async def close_fx_gui(req: FxGuiCloseRequest, current_user: dict = Depends(get_
plugin_path = (req.path or "").strip()
if not plugin_path:
raise HTTPException(status_code=400, detail="Thiếu đường dẫn VST plugin.")
# M4: plugin đang mở native (editor instance DSP trong session fxrt) →
# gửi ed.close (đóng editor, GIỮ instance + state — mở lại nhanh).
_entry = fx_realtime.editor_find(plugin_path)
if _entry:
_sid, _slot = _entry
fx_realtime.editor_unregister(plugin_path)
_sess = fx_realtime.get_session(_sid)
if _sess is not None and _sess.alive():
_sess.send_editor_cmd("close", slot=_slot)
return {"success": True, "closed": True, "native": True}
# session đã chết — rơi xuống spawn fallback (đóng bridge fx-gui cũ nếu 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:
proc.terminate()
@@ -820,6 +849,49 @@ async def close_fx_gui(req: FxGuiCloseRequest, current_user: dict = Depends(get_
return {"success": True, "closed": False}
class FxGuiNativeRequest(BaseModel):
"""Hide/Show GUI native (editor instance DSP trong session fxrt)."""
path: str
def _fxrt_native_editor(plugin_path):
"""(session, slot) nếu plugin đang mở native trong session fxrt sống."""
entry = fx_realtime.editor_find(plugin_path)
if not entry:
return None, None
sess = fx_realtime.get_session(entry[0])
if sess is None or not sess.alive():
fx_realtime.editor_unregister(plugin_path)
return None, None
return sess, entry[1]
@router.post("/fx-gui/hide")
async def hide_fx_gui(req: FxGuiNativeRequest,
current_user: dict = Depends(get_current_user)):
"""Ẩn GUI native (ed.hide — giữ editor + instance, mở lại nhanh). Chỉ
áp dụng editor in-process fxrt; GUI spawn mode (HTTP bridge) tự có /hide
riêng — trả applied=False không lỗi."""
enforce_password_changed(current_user)
sess, slot = _fxrt_native_editor((req.path or "").strip())
if sess is None:
return {"success": True, "applied": False}
sess.send_editor_cmd("hide", slot=slot)
return {"success": True, "applied": True, "native": True}
@router.post("/fx-gui/show")
async def show_fx_gui(req: FxGuiNativeRequest,
current_user: dict = Depends(get_current_user)):
"""Hiện lại GUI native (ed.show)."""
enforce_password_changed(current_user)
sess, slot = _fxrt_native_editor((req.path or "").strip())
if sess is None:
return {"success": True, "applied": False}
sess.send_editor_cmd("show", slot=slot)
return {"success": True, "applied": True, "native": True}
_FX_GUI_PROCESSES = [] # list[subprocess.Popen] — các bridge fx-gui do app spawn
def _register_fx_gui_process(proc):
+144
View File
@@ -16,6 +16,7 @@ Luồng dữ liệu: browser → WS → write_input() → [bridge xử lý] →
"""
import ctypes
import json
import logging
import os
import subprocess
import tempfile
@@ -34,6 +35,11 @@ FXRT_LAT_SLOTS = 8
FXRT_STATE_STARTING = 0
FXRT_STATE_READY = 1
FXRT_STATE_ERROR = 2
# M4.5 watchdog: số lần AUTO restart tối đa sau crash (editor in-process chết
# không được giết session mãi — Reaper chấp nhận gap audio khi plugin crash).
FXRT_MAX_RESTARTS = 3
_log = logging.getLogger("fx_realtime")
class FxRTHeader(ctypes.Structure):
@@ -131,7 +137,14 @@ class FxRealtimeSession:
self.stats = {'publish': 0, 'drop_batch': 0, 'drop_blocks': 0,
'stale_flush': 0, 'read_blocks': 0, 'read_empty': 0,
'flush': 0}
# M4.5 watchdog (chính sách FXRealtime đã duyệt): proc chết ngoài ý
# muốn (crash view/plugin/loop) → AUTO restart; user kill / close() có
# chủ đích → KHÔNG restart (cờ _closing / running=0 — user gọi lại).
self._closing = False
self._restarts = 0
self._watch = threading.Thread(target=self._watch_loop, daemon=True)
self._spawn()
self._watch.start()
# ── process ─────────────────────────────────────────────────────────────
def _chain_json_for_bridge(self):
@@ -174,6 +187,13 @@ class FxRealtimeSession:
"seq": self._chain_seq,
"fx_chain": self._chain_json_for_bridge(),
}
# M4.5 respawn: job cũ không ai đọc (proc cũ đã chết) — dọn rác.
_old_job = self._job_path
if _old_job and os.path.exists(_old_job):
try:
os.remove(_old_job)
except Exception:
pass
fd, self._job_path = tempfile.mkstemp(suffix=".json", prefix="fxrt_")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(job, f)
@@ -235,6 +255,7 @@ class FxRealtimeSession:
return self.proc is not None and self.proc.poll() is None
def close(self):
self._closing = True # M4.5: watchdog không auto-restart khi close chủ đích
try:
self.h.running = 0
except Exception:
@@ -426,11 +447,133 @@ class FxRealtimeSession:
except Exception:
pass
# ── editor control (Phase 2 — Reaper-style) ─────────────────────────────
# M4: engine điều khiển editor của CHÍNH instance DSP trong juce_fx_bridge
# qua SHM ctrl ring (key ed.*). Không spawn instance 2 / feeder.
def send_editor_cmd(self, action, slot, w=0, h=0):
"""Gửi lệnh editor (ed.open/close/show/hide/resize/capture) cho slot
chain. w/h encode w*4096+h (chính xác tuyệt đối w,h<=4095 — float
24-bit mantissa; xem M3); w,h<=0 → editor preferred size (bridge chỉ
setSize khi w,h>0). Trả False nếu session không có SHM (fake object)."""
key = "ed.%s" % (action or "").strip().lower()
value = 0.0
try:
if int(w) > 0 and int(h) > 0:
value = float(int(w) * 4096 + int(h))
except Exception:
value = 0.0
with self._lock:
if getattr(self, "shm", None) is None:
return False
try:
self._ctrl_enqueue_locked(int(slot), key, value)
return True
except Exception:
return False
def chain_slot_for_path(self, plugin_path):
"""Slot index (theo chain bridge ĐANG CHẠY — đã lọc active=false, giữ
thứ tự) của plugin path; None nếu plugin không trong chain. Index khớp
editors[] của JuceFxEngine (C++) — ed.* gửi theo index này."""
norm = _norm_plugin_path(plugin_path or "")
if not norm:
return None
for i, s in enumerate(self._chain_json_for_bridge()):
p = (s.get("path") or "")
if p and _norm_plugin_path(p) == norm:
return i
return None
def _watch_loop(self):
"""M4.5 watchdog — thread riêng (ngoài audio). Proc chết ngoài ý muốn
(crash view/plugin/loop) → AUTO restart tối đa FXRT_MAX_RESTARTS lần,
log rc để đối chiếu CPU 100%. Không restart khi: close() chủ đích
(_closing) / running=0 (user kill — user gọi lại). Respawn đúng cmd
line (--juce-fx <job> --shm <name> --parent <pid>) qua _spawn()."""
while True:
time.sleep(0.2)
with self._lock:
if self._closing:
return
proc = self.proc
if proc is None:
return
rc = proc.poll()
if rc is None:
continue
if self._closing:
return
if getattr(self.h, "running", 1) == 0:
_log.warning("[fxrt] bridge thoát rc=%s sau running=0 (user kill — không restart)", rc)
return
if self._restarts >= FXRT_MAX_RESTARTS:
_log.error("[fxrt] bridge crash rc=%s — restart tối đa %d lần đã đạt, bỏ", rc, FXRT_MAX_RESTARTS)
return
self._restarts += 1
_log.warning("[fxrt] bridge chết rc=%s — auto restart #%d", rc, self._restarts)
try:
self._spawn()
except Exception as e:
_log.error("[fxrt] respawn lỗi: %s", e)
return
# Chờ READY (thread riêng — không block API); crash lần nữa → vòng
# ngoài restart tiếp (đếm _restarts đã tăng → dừng đúng giới hạn).
deadline = time.time() + 8.0
while time.time() < deadline:
time.sleep(0.1)
with self._lock:
if self._closing or self.proc is None:
return
if self.proc.poll() is not None:
break
if self.h.state == FXRT_STATE_READY:
break
# Registry toàn tiến trình (desktop app 1 user).
_SESSIONS = {}
_SESSIONS_LOCK = threading.Lock()
def _norm_plugin_path(p):
"""Chuẩn hóa path plugin để so khớp (Windows case-insensitive)."""
p = (p or "").strip()
if not p:
return ""
return os.path.normcase(os.path.normpath(p))
# M4: mapping plugin path -> (session_id, slot) — GUI native đang mở (editor
# instance DSP trong session fxrt). /fx-gui/{close,hide,show} chỉ nhận path →
# tìm session qua registry này. Session chết → entry dọn khi dùng/stop.
_FXRT_EDITORS = {}
_FXRT_EDITORS_LOCK = threading.Lock()
def editor_register(plugin_path, session_id, slot):
key = _norm_plugin_path(plugin_path)
if not key:
return
with _FXRT_EDITORS_LOCK:
_FXRT_EDITORS[key] = (session_id, int(slot))
def editor_find(plugin_path):
key = _norm_plugin_path(plugin_path)
with _FXRT_EDITORS_LOCK:
return _FXRT_EDITORS.get(key)
def editor_unregister(plugin_path):
key = _norm_plugin_path(plugin_path)
with _FXRT_EDITORS_LOCK:
_FXRT_EDITORS.pop(key, None)
def editor_unregister_session(session_id):
with _FXRT_EDITORS_LOCK:
for k in [k for k, v in _FXRT_EDITORS.items() if v[0] == session_id]:
_FXRT_EDITORS.pop(k, None)
def start_session(fx_chain, sample_rate, block_size=FXRT_BLOCK):
sess = FxRealtimeSession(fx_chain, sample_rate, block_size)
try:
@@ -453,6 +596,7 @@ def stop_session(session_id):
sess = _SESSIONS.pop(session_id, None)
if sess:
sess.close()
editor_unregister_session(session_id) # M4: dọn mapping GUI native
return sess is not None