fix(fx): realtime VST ngắt quãng khi mở MASTERING PANEL + PWR master không ảnh hưởng âm; pacing WS input/output, gate fxRt theo masterConnected, worklet cap 32

This commit is contained in:
2026-08-21 21:48:56 +07:00
parent 4e3f2fd08a
commit bb6658e03d
17 changed files with 1813 additions and 142 deletions
+14 -8
View File
@@ -9,13 +9,19 @@ router = APIRouter()
# WebView2 standalone chặn anchor-download → không thấy save dialog. Giải pháp:
# frontend gọi /save-dialog → engine mở NATIVE SaveFileDialog (Windows Explorer)
# → trả đường dẫn user chọn → frontend gửi bytes qua /write → ghi file thật.
SAVE_DIALOG_TIMEOUT = 120 # user có thể để dialog mở lâu
SAVE_DIALOG_TIMEOUT = 120 # fallback native (PowerShell) — user có thể để dialog mở lâu
# Bridge timeout phải LỚN HƠN Rust wait (300s trong lib.rs) — nếu không, user
# chọn path sau 300s thì engine đã bỏ cuộc → save_file.response bị bỏ lại, file
# không được ghi dù user đã chọn nơi lưu (bug "hỏi nơi lưu nhưng không có file").
SAVE_BRIDGE_TIMEOUT = 360
def _save_file_via_tauri_bridge(default_name: str) -> Optional[str]:
"""Tauri shell (Rust watcher trong lib.rs) mở NATIVE SaveFileDialog qua
file IPC: engine ghi save_file.request (chứa tên mặc định) → Rust mở dialog
→ ghi save_file.response. Trả None nếu bridge không tồn tại (standalone)."""
→ ghi save_file.response. Trả None nếu bridge không tồn tại (standalone) →
caller fallback native. Trả "" nếu bridge ĐÃ dùng nhưng user hủy / hết hạn →
KHÔNG fallback (tránh 2 dialog cùng lúc + tránh ghi đè path user đã chọn)."""
root = os.environ.get("APPDATA") or os.path.expanduser("~")
ipc = os.path.join(root, "SonicForgeDAW", "ipc")
if not os.path.isdir(ipc):
@@ -35,7 +41,7 @@ def _save_file_via_tauri_bridge(default_name: str) -> Optional[str]:
os.remove(f)
with open(req, "w", encoding="utf-8") as fh:
fh.write(default_name or "untitled")
deadline = _time.time() + SAVE_DIALOG_TIMEOUT
deadline = _time.time() + SAVE_BRIDGE_TIMEOUT
while _time.time() < deadline:
if os.path.exists(resp):
try:
@@ -43,11 +49,11 @@ def _save_file_via_tauri_bridge(default_name: str) -> Optional[str]:
val = fh.read().strip()
finally:
os.remove(resp)
return val or None
return val # "" khi user hủy — KHÔNG fallback PowerShell (tránh 2 dialog)
_time.sleep(0.1)
except Exception:
pass
return None
return ""
return "" # bridge đã dùng nhưng hết hạn — KHÔNG fallback (dialog Rust vẫn có thể đang mở)
def _save_file_native_engine(default_name: str, filter_name: str, filter_ext: str) -> Optional[str]:
@@ -107,9 +113,9 @@ def save_file_dialog(req: SaveDialogRequest):
Trả {"path": "<đường dẫn user chọn>"} hoặc {"path": None} (hủy/không có
dialog). def (sync) → FastAPI chạy threadpool — không block loop."""
path = _save_file_via_tauri_bridge(req.default_name)
if path is None:
if path is None: # không có bridge (standalone thuần) → fallback native
path = _save_file_native_engine(req.default_name, req.filter_name, req.filter_ext)
return {"path": path}
return {"path": path or None}
@router.post("/write")
+112 -3
View File
@@ -1,7 +1,7 @@
import os, sys, uuid, json, tempfile, subprocess, time as _time, threading
import os, sys, uuid, json, tempfile, subprocess, time as _time, threading, asyncio
import numpy as np
import soundfile as sf
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header, Query
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, BackgroundTasks, Header, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional, Any
@@ -12,6 +12,8 @@ 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
from app.core import fx_realtime
from app.core.auth import decode_token
router = APIRouter()
@@ -1179,7 +1181,12 @@ async def fx_render(req: FxRenderRequest, current_user: dict = Depends(get_curre
if not fid:
raise HTTPException(status_code=400,
detail="Cần input_file_id hoặc input_path")
src = os.path.join(settings.PROCESSED_DIR, os.path.basename(fid))
# 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}")
@@ -1222,6 +1229,108 @@ def _render_fx_locked(input_path: str, fx_chain: list, sample_rate: int, out_pat
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 = ""
@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.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. Desktop app có thể chưa login → token rỗng được
# phép (endpoint /start vẫn yêu cầu auth).
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
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():
# Pace mỗi block theo tốc độ âm thật (256/sr ~ 5.8ms): bridge sản xuất
# output theo burst khi nhận input dồn; bắn toàn bộ cùng lúc → worklet
# overflow drop block cũ nhất → âm ngắt quãng.
block_sec = (sess.block_size or 256) / max(int(sess.sample_rate or 44100), 1)
try:
while True:
blks = sess.read_output()
if not blks:
await asyncio.sleep(0.002)
continue
for blk in blks:
await websocket.send_bytes(blk)
await asyncio.sleep(block_sec)
except Exception:
pass
pump_task = asyncio.create_task(_pump_output())
try:
while True:
data = await websocket.receive_bytes()
if not sess.alive():
break
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í