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:
+14
-8
@@ -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
@@ -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í
|
||||
|
||||
Reference in New Issue
Block a user