From 14fd794e6dcf934dfb4c6ed3ccac111d7b729fb3 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Fri, 4 Sep 2026 11:37:08 +0700 Subject: [PATCH] M4: /fx-gui redirect vao session fxrt (native-window mode, embed_url=None) + watchdog auto-restart --- app/api/v1/plugins.py | 72 ++++++++++++++++++++ app/core/fx_realtime.py | 144 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index fc7ab47..fc2dab2 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -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): diff --git a/app/core/fx_realtime.py b/app/core/fx_realtime.py index 048c97c..ff959c8 100644 --- a/app/core/fx_realtime.py +++ b/app/core/fx_realtime.py @@ -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 --shm --parent ) 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