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í
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
# app/core/fx_realtime.py
|
||||
"""VST FX SHM realtime (cloud-FX loop): engine tạo mapping FxRealtimeIPC,
|
||||
spawn fx_vst_bridge --realtime-fx, đẩy/kéo block audio qua SHM ring.
|
||||
|
||||
Layout mirror chính xác native_bridge/include/FxRealtimeIPC.h (mọi field
|
||||
u32/float 4-byte, không padding):
|
||||
header 12 u32 (48B): magic state sampleRate blockSize running heartbeat
|
||||
inWrite inRead inSlots outWrite outRead outSlots
|
||||
inL[4][256] inR[4][256] outL[8][256] outR[8][256]
|
||||
|
||||
Luồng dữ liệu: browser → WS → write_input() → [bridge xử lý] → read_output()
|
||||
→ WS → browser. Block = FXRT_BLOCK=256 stereo float32 = 2048 bytes.
|
||||
"""
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.config import settings
|
||||
from app.core.native_render import find_fx_bridge_exe
|
||||
|
||||
FXRT_MAGIC = 0x46585254
|
||||
FXRT_BLOCK = 256
|
||||
FXRT_IN_SLOTS = 4
|
||||
FXRT_OUT_SLOTS = 8
|
||||
FXRT_STATE_STARTING = 0
|
||||
FXRT_STATE_READY = 1
|
||||
FXRT_STATE_ERROR = 2
|
||||
|
||||
|
||||
class FxRTHeader(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("magic", ctypes.c_uint32),
|
||||
("state", ctypes.c_uint32),
|
||||
("sample_rate", ctypes.c_uint32),
|
||||
("block_size", ctypes.c_uint32),
|
||||
("running", ctypes.c_uint32),
|
||||
("heartbeat", ctypes.c_uint32),
|
||||
("in_write", ctypes.c_uint32),
|
||||
("in_read", ctypes.c_uint32),
|
||||
("in_slots", ctypes.c_uint32),
|
||||
("out_write", ctypes.c_uint32),
|
||||
("out_read", ctypes.c_uint32),
|
||||
("out_slots", ctypes.c_uint32),
|
||||
]
|
||||
|
||||
|
||||
HEADER_SIZE = ctypes.sizeof(FxRTHeader) # 48
|
||||
IN_L_OFF = HEADER_SIZE # 48
|
||||
IN_R_OFF = IN_L_OFF + FXRT_IN_SLOTS * FXRT_BLOCK * 4
|
||||
OUT_L_OFF = IN_R_OFF + FXRT_IN_SLOTS * FXRT_BLOCK * 4
|
||||
OUT_R_OFF = OUT_L_OFF + FXRT_OUT_SLOTS * FXRT_BLOCK * 4
|
||||
TOTAL_SIZE = OUT_R_OFF + FXRT_OUT_SLOTS * FXRT_BLOCK * 4
|
||||
|
||||
|
||||
class FxRealtimeSession:
|
||||
"""Một session realtime: SHM + subprocess fx_vst_bridge + I/O block."""
|
||||
|
||||
def __init__(self, fx_chain, sample_rate, block_size=FXRT_BLOCK):
|
||||
from multiprocessing import shared_memory
|
||||
self.fx_chain = fx_chain or []
|
||||
self.sample_rate = int(sample_rate)
|
||||
self.block_size = int(block_size or FXRT_BLOCK)
|
||||
if self.block_size != FXRT_BLOCK:
|
||||
raise ValueError("block_size phải = 256 (khớp C++ FxRealtimeIPC)")
|
||||
self.name = "SonicForge_FXRT_" + uuid.uuid4().hex[:12]
|
||||
self.shm = shared_memory.SharedMemory(name=self.name, create=True,
|
||||
size=TOTAL_SIZE)
|
||||
self.shm.buf[:TOTAL_SIZE] = b"\x00" * TOTAL_SIZE
|
||||
self.h = FxRTHeader.from_buffer(self.shm.buf)
|
||||
self.h.magic = FXRT_MAGIC
|
||||
self.h.state = FXRT_STATE_STARTING
|
||||
self.h.sample_rate = self.sample_rate
|
||||
self.h.block_size = self.block_size
|
||||
self.h.running = 1
|
||||
self.h.in_slots = FXRT_IN_SLOTS
|
||||
self.h.out_slots = FXRT_OUT_SLOTS
|
||||
self._np = np.frombuffer(self.shm.buf, dtype=np.float32)
|
||||
self._lock = threading.Lock()
|
||||
self.proc = None
|
||||
self._job_path = ""
|
||||
self._started_at = time.time()
|
||||
self._spawn()
|
||||
|
||||
# ── process ─────────────────────────────────────────────────────────────
|
||||
def _chain_json_for_bridge(self):
|
||||
"""JSON mảng {path,bypass,preset_b64} — RealtimeFxChain::buildChain đọc
|
||||
đúng các field này (type/name bỏ qua)."""
|
||||
out = []
|
||||
for s in self.fx_chain:
|
||||
if not s or not s.get("path"):
|
||||
continue
|
||||
out.append({
|
||||
"path": s["path"],
|
||||
"bypass": bool(s.get("bypass")),
|
||||
"preset_b64": s.get("preset_b64") or "",
|
||||
})
|
||||
return out
|
||||
|
||||
def _spawn(self):
|
||||
exe = find_fx_bridge_exe()
|
||||
if not exe:
|
||||
raise RuntimeError("Không tìm thấy fx_vst_bridge.exe")
|
||||
job = {
|
||||
"sample_rate": self.sample_rate,
|
||||
"block_size": self.block_size,
|
||||
"fx_chain": self._chain_json_for_bridge(),
|
||||
}
|
||||
fd, self._job_path = tempfile.mkstemp(suffix=".json", prefix="fxrt_")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(job, f)
|
||||
cmd = [exe, "--realtime-fx", self._job_path, "--shm", self.name,
|
||||
"--parent", str(os.getpid())]
|
||||
self.proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
|
||||
def wait_ready(self, timeout=30.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if self.proc.poll() is not None:
|
||||
raise RuntimeError(
|
||||
f"fx_vst_bridge thoát sớm (rc={self.proc.returncode})")
|
||||
if self.h.state == FXRT_STATE_READY:
|
||||
return True
|
||||
time.sleep(0.02)
|
||||
raise RuntimeError("fx_vst_bridge không sẵn sàng trong %ss" % timeout)
|
||||
|
||||
def alive(self):
|
||||
return self.proc is not None and self.proc.poll() is None
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.h.running = 0
|
||||
except Exception:
|
||||
pass
|
||||
if self.proc is not None:
|
||||
try:
|
||||
self.proc.wait(timeout=3)
|
||||
except Exception:
|
||||
try:
|
||||
self.proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self.proc = None
|
||||
try:
|
||||
self.shm.close()
|
||||
self.shm.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
if self._job_path and os.path.exists(self._job_path):
|
||||
try:
|
||||
os.remove(self._job_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── audio I/O ───────────────────────────────────────────────────────────
|
||||
def write_input(self, interleaved):
|
||||
"""Ghi 1 block stereo float32 (2048 bytes) vào input ring. Ring đầy →
|
||||
drop block cũ nhất (giữ latency thấp)."""
|
||||
arr = np.frombuffer(interleaved, dtype=np.float32)
|
||||
if arr.size != self.block_size * 2:
|
||||
return
|
||||
arr = arr.reshape(self.block_size, 2)
|
||||
with self._lock:
|
||||
h = self.h
|
||||
while True:
|
||||
if h.in_write - h.in_read < h.in_slots:
|
||||
slot = h.in_write & (h.in_slots - 1)
|
||||
bl = IN_L_OFF // 4 + slot * self.block_size
|
||||
br = IN_R_OFF // 4 + slot * self.block_size
|
||||
self._np[bl:bl + self.block_size] = arr[:, 0]
|
||||
self._np[br:br + self.block_size] = arr[:, 1]
|
||||
h.in_write += 1
|
||||
return
|
||||
h.in_read += 1 # full → drop oldest input
|
||||
|
||||
def read_output(self):
|
||||
"""Drain output ring → list bytes (mỗi block 2048 bytes interleaved)."""
|
||||
out = []
|
||||
with self._lock:
|
||||
h = self.h
|
||||
while h.out_read < h.out_write:
|
||||
slot = h.out_read & (h.out_slots - 1)
|
||||
bl = OUT_L_OFF // 4 + slot * self.block_size
|
||||
br = OUT_R_OFF // 4 + slot * self.block_size
|
||||
inter = np.empty(self.block_size * 2, dtype=np.float32)
|
||||
inter[0::2] = self._np[bl:bl + self.block_size]
|
||||
inter[1::2] = self._np[br:br + self.block_size]
|
||||
out.append(inter.tobytes())
|
||||
h.out_read += 1
|
||||
return out
|
||||
|
||||
def heartbeat_age(self):
|
||||
return time.time() - self._started_at # placeholder: engine đọc h.heartbeat
|
||||
|
||||
|
||||
# Registry toàn tiến trình (desktop app 1 user).
|
||||
_SESSIONS = {}
|
||||
_SESSIONS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def start_session(fx_chain, sample_rate, block_size=FXRT_BLOCK):
|
||||
sess = FxRealtimeSession(fx_chain, sample_rate, block_size)
|
||||
try:
|
||||
sess.wait_ready(timeout=30)
|
||||
except Exception:
|
||||
sess.close()
|
||||
raise
|
||||
with _SESSIONS_LOCK:
|
||||
_SESSIONS[sess.name] = sess
|
||||
return sess
|
||||
|
||||
|
||||
def get_session(session_id):
|
||||
with _SESSIONS_LOCK:
|
||||
return _SESSIONS.get(session_id)
|
||||
|
||||
|
||||
def stop_session(session_id):
|
||||
with _SESSIONS_LOCK:
|
||||
sess = _SESSIONS.pop(session_id, None)
|
||||
if sess:
|
||||
sess.close()
|
||||
return sess is not None
|
||||
|
||||
|
||||
def stop_all():
|
||||
with _SESSIONS_LOCK:
|
||||
ids = list(_SESSIONS.keys())
|
||||
for sid in ids:
|
||||
stop_session(sid)
|
||||
+276
-41
@@ -63,7 +63,235 @@ def _find_default_sf2() -> str:
|
||||
return p
|
||||
return ""
|
||||
|
||||
# ── Builtin custom FX (spec fx_chain_architecture §2 Step 2: Custom FX) ─────
|
||||
# Track FX chain slots: eq / eqpro / compressor / limiter / exciter / rebalance
|
||||
# (khớp tham số WebAudio track FX modules + EQ Pro bands) + legacy chorus/reverb
|
||||
# (fx_type cũ). Chạy numpy/scipy thuần — thay pedalboard GPL; lazy import scipy
|
||||
# chỉ khi thực sự có FX (không tăng boot time).
|
||||
|
||||
def _rbj_peaking(f0, gain_db, q, sr):
|
||||
A = 10 ** (gain_db / 40.0)
|
||||
w0 = 2 * math.pi * f0 / sr
|
||||
alpha = math.sin(w0) / (2 * q)
|
||||
cw = math.cos(w0)
|
||||
a0 = 1 + alpha / A
|
||||
b0 = 1 + alpha * A
|
||||
b1 = -2 * cw
|
||||
b2 = 1 - alpha * A
|
||||
a1 = -2 * cw
|
||||
a2 = 1 - alpha / A
|
||||
return [b0 / a0, b1 / a0, b2 / a0], [1.0, a1 / a0, a2 / a0]
|
||||
|
||||
def _rbj_shelf(f0, gain_db, q, sr, low):
|
||||
A = 10 ** (gain_db / 40.0)
|
||||
w0 = 2 * math.pi * f0 / sr
|
||||
alpha = math.sin(w0) / (2 * q)
|
||||
cw = math.cos(w0)
|
||||
sA = 2 * math.sqrt(A) * alpha
|
||||
if low:
|
||||
b0 = A * ((A + 1) - (A - 1) * cw + sA)
|
||||
b1 = 2 * A * ((A - 1) - (A + 1) * cw)
|
||||
b2 = A * ((A + 1) - (A - 1) * cw - sA)
|
||||
a0 = (A + 1) + (A - 1) * cw + sA
|
||||
a1 = -2 * ((A - 1) + (A + 1) * cw)
|
||||
a2 = (A + 1) + (A - 1) * cw - sA
|
||||
else:
|
||||
b0 = A * ((A + 1) + (A - 1) * cw + sA)
|
||||
b1 = -2 * A * ((A - 1) + (A + 1) * cw)
|
||||
b2 = A * ((A + 1) + (A - 1) * cw - sA)
|
||||
a0 = (A + 1) - (A - 1) * cw + sA
|
||||
a1 = 2 * ((A - 1) - (A + 1) * cw)
|
||||
a2 = (A + 1) - (A - 1) * cw - sA
|
||||
return [b0 / a0, b1 / a0, b2 / a0], [1.0, a1 / a0, a2 / a0]
|
||||
|
||||
def _rbj_highpass(f0, q, sr):
|
||||
w0 = 2 * math.pi * f0 / sr
|
||||
alpha = math.sin(w0) / (2 * q)
|
||||
cw = math.cos(w0)
|
||||
a0 = 1 + alpha
|
||||
b0 = (1 + cw) / 2
|
||||
b1 = -(1 + cw)
|
||||
b2 = (1 + cw) / 2
|
||||
a1 = -2 * cw
|
||||
a2 = 1 - alpha
|
||||
return [b0 / a0, b1 / a0, b2 / a0], [1.0, a1 / a0, a2 / a0]
|
||||
|
||||
def _apply_biquad(buf, b, a):
|
||||
from scipy.signal import lfilter
|
||||
out = np.empty_like(buf)
|
||||
for ch in range(buf.shape[0]):
|
||||
out[ch] = lfilter(b, a, buf[ch])
|
||||
return out
|
||||
|
||||
def _apply_eq4(buf, params, sr):
|
||||
params = params or {}
|
||||
gains = [float(params.get(k, 0) or 0) for k in ("g1", "g2", "g3", "g4")]
|
||||
if not any(gains):
|
||||
return buf
|
||||
# WebAudio track 'eq': lowshelf 100Hz, peaking 800Hz Q0.7, peaking 3200Hz
|
||||
# Q1.2, highshelf 10kHz — cùng thứ tự/đáp ứng.
|
||||
for (f0, gain, q, low) in ((100, gains[0], 0.707, True),
|
||||
(800, gains[1], 0.7, False),
|
||||
(3200, gains[2], 1.2, False),
|
||||
(10000, gains[3], 0.707, False)):
|
||||
if gain == 0:
|
||||
continue
|
||||
b, a = (_rbj_shelf(f0, gain, q, sr, True) if low
|
||||
else _rbj_peaking(f0, gain, q, sr))
|
||||
buf = _apply_biquad(buf, b, a)
|
||||
return buf
|
||||
|
||||
def _apply_eqpro(buf, params, sr):
|
||||
params = params or {}
|
||||
bands = params.get("bands") or []
|
||||
if not bands:
|
||||
return buf
|
||||
amount = float(params.get("amount", 100) or 100) / 100.0
|
||||
for b in bands:
|
||||
if b.get("active") is False:
|
||||
continue
|
||||
gain = (float(b.get("gain", 0) or 0)) * amount
|
||||
if gain == 0:
|
||||
continue
|
||||
f0 = float(b.get("freq", 1000))
|
||||
q = float(b.get("q", 1.0) or 1.0)
|
||||
typ = b.get("type", "peaking")
|
||||
if typ in ("lowshelf", "highshelf"):
|
||||
bq, aq = _rbj_shelf(f0, gain, q, sr, typ == "lowshelf")
|
||||
elif typ == "highpass":
|
||||
bq, aq = _rbj_highpass(f0, q, sr)
|
||||
else: # peaking / lowpass / notch / bandpass → peaking (biquad gần đúng)
|
||||
bq, aq = _rbj_peaking(f0, gain, q, sr)
|
||||
buf = _apply_biquad(buf, bq, aq)
|
||||
return buf
|
||||
|
||||
def _apply_compressor(buf, params, sr):
|
||||
params = params or {}
|
||||
threshold_db = float(params.get("threshold", -16))
|
||||
ratio = max(1.0, float(params.get("ratio", 3) or 3))
|
||||
makeup_db = float(params.get("makeup", 0) or 0)
|
||||
makeup = 10 ** (makeup_db / 20.0)
|
||||
block = 256
|
||||
out = np.empty_like(buf)
|
||||
rel = math.exp(-1.0 / (sr * 0.25)) # release 250ms, khớp WebAudio default
|
||||
for ch in range(buf.shape[0]):
|
||||
x = buf[ch]
|
||||
n = x.shape[0]
|
||||
nblocks = (n + block - 1) // block
|
||||
gr = np.ones(n, dtype=np.float32)
|
||||
env = 0.0
|
||||
for bi in range(nblocks):
|
||||
seg = x[bi * block:(bi + 1) * block]
|
||||
peak = float(np.max(np.abs(seg))) if seg.size else 0.0
|
||||
env = max(peak, env * rel) # release smoothing giữa block
|
||||
if env > 1e-9:
|
||||
db = 20 * math.log10(env)
|
||||
over = db - threshold_db
|
||||
if over > 0:
|
||||
gain_db = -over * (1.0 - 1.0 / ratio)
|
||||
gr[bi * block:(bi + 1) * block] = (10 ** (gain_db / 20.0)) * makeup
|
||||
else:
|
||||
gr[bi * block:(bi + 1) * block] = makeup
|
||||
out[ch] = x * gr
|
||||
return out
|
||||
|
||||
def _apply_limiter(buf, params, sr):
|
||||
params = params or {}
|
||||
ceiling_db = min(0.0, float(params.get("ceiling", -1.0)))
|
||||
th = 10 ** (ceiling_db / 20.0)
|
||||
k = 1.0 / max(0.02, th)
|
||||
tanh_k = math.tanh(k)
|
||||
# Brickwall tanh soft-clip — mirror WebAudio limiter (tránh NaN của
|
||||
# DynamicsCompressor Chromium; offline dùng cùng curve). WaveShaper
|
||||
# curve chỉ định nghĩa trên [-1,1] → clamp đầu vào như WebAudio.
|
||||
x = np.clip(buf, -1.0, 1.0)
|
||||
return np.tanh(x * k) / tanh_k
|
||||
|
||||
def _apply_exciter(buf, params, sr):
|
||||
params = params or {}
|
||||
drive = float(params.get("drive", 40) or 40)
|
||||
b, a = _rbj_highpass(2000.0, 0.7, sr)
|
||||
hp = _apply_biquad(buf, b, a)
|
||||
wet = (drive / 100.0) * 0.6
|
||||
return buf + np.tanh(hp * 3.0) * wet
|
||||
|
||||
def _apply_rebalance(buf, params, sr):
|
||||
params = params or {}
|
||||
mid = 10 ** (float(params.get("mid", 0) or 0) / 20.0)
|
||||
side = 10 ** (float(params.get("side", 0) or 0) / 20.0)
|
||||
a = (mid + side) / 2.0
|
||||
b = (mid - side) / 2.0
|
||||
L = buf[0]
|
||||
R = buf[1]
|
||||
return np.stack([a * L + b * R, b * L + a * R]).astype(np.float32)
|
||||
|
||||
def _apply_builtin_fx_chain(buf, chain, sr):
|
||||
"""Serial pipeline qua các builtin custom FX slots (spec §2 Step 2: Slot 1..n
|
||||
in-place). Slot inactive/bypass → bỏ qua. vst3/carla slots = xử lý ngoài
|
||||
(bridge) → không thêm DSP ở đây."""
|
||||
for slot in chain or []:
|
||||
if isinstance(slot, str):
|
||||
slot = {"type": slot}
|
||||
if not isinstance(slot, dict):
|
||||
continue
|
||||
if slot.get("active") is False or slot.get("bypass"):
|
||||
continue
|
||||
typ = slot.get("type") or ""
|
||||
params = slot.get("params") or {}
|
||||
try:
|
||||
if typ == "eq":
|
||||
buf = _apply_eq4(buf, params, sr)
|
||||
elif typ == "eqpro":
|
||||
buf = _apply_eqpro(buf, params, sr)
|
||||
elif typ == "compressor":
|
||||
buf = _apply_compressor(buf, params, sr)
|
||||
elif typ == "limiter":
|
||||
buf = _apply_limiter(buf, params, sr)
|
||||
elif typ == "exciter":
|
||||
buf = _apply_exciter(buf, params, sr)
|
||||
elif typ == "rebalance":
|
||||
buf = _apply_rebalance(buf, params, sr)
|
||||
except Exception as e:
|
||||
logger.warning("[RenderEngine] Builtin FX slot %s failed: %s", typ, e)
|
||||
return buf
|
||||
|
||||
def _apply_legacy_chorus(buf, sr):
|
||||
total_samples = buf.shape[1]
|
||||
lfo = 0.020 + 0.005 * np.sin(2 * np.pi * 1.5 * np.arange(total_samples) / sr)
|
||||
dry = buf * 0.6
|
||||
wet = np.zeros_like(buf)
|
||||
for ch in range(2):
|
||||
indices = np.arange(total_samples) - (lfo * sr)
|
||||
indices = np.clip(indices, 0, total_samples - 1).astype(np.int32)
|
||||
wet[ch, :] = buf[ch, indices]
|
||||
return dry + wet * 0.5
|
||||
|
||||
def _apply_legacy_reverb(buf, sr):
|
||||
total_samples = buf.shape[1]
|
||||
len_ir = int(sr * 2.0)
|
||||
t_ir = np.arange(len_ir) / sr
|
||||
decay = np.exp(-t_ir / 0.5)
|
||||
ir_l = (np.random.rand(len_ir) * 2 - 1) * decay
|
||||
ir_r = (np.random.rand(len_ir) * 2 - 1) * decay
|
||||
dry = buf * 0.6
|
||||
wet = np.zeros_like(buf)
|
||||
for ch in range(2):
|
||||
ir = ir_l if ch == 0 else ir_r
|
||||
from scipy.signal import convolve
|
||||
conv = convolve(buf[ch, :], ir, mode='full')[:total_samples]
|
||||
wet[ch, :] = conv
|
||||
return dry + wet * 0.4
|
||||
|
||||
def _track_latency_samples(track):
|
||||
"""PDC (spec §3): tổng latency (samples) của track từ chain slots. Plugin
|
||||
khai qua field `latency_samples` (bridge hiện chưa báo → 0); khi có
|
||||
getLatencySamples() native, slot điền field này và engine tự align."""
|
||||
total = 0
|
||||
for chain_key in ("fx_chain", "vst_fx_chain"):
|
||||
for slot in (track.get(chain_key) or []):
|
||||
if isinstance(slot, dict) and slot.get("latency_samples"):
|
||||
total += int(slot["latency_samples"])
|
||||
return total
|
||||
|
||||
class PythonRenderEngine:
|
||||
def __init__(self, sample_rate=44100):
|
||||
@@ -102,6 +330,17 @@ class PythonRenderEngine:
|
||||
tracks = session.get("tracks", [])
|
||||
solo_ids = {t.get("id") for t in tracks if t.get("solo")}
|
||||
|
||||
# PDC pre-scan (spec §3): latency mỗi track từ chain slots; track ngắn
|
||||
# hơn bị delay khi mix để mọi track sum sample-accurate phase-aligned.
|
||||
_track_lat = {}
|
||||
_max_lat = 0
|
||||
for _t in tracks:
|
||||
if solo_ids and _t.get("id") not in solo_ids:
|
||||
continue
|
||||
_lat = _track_latency_samples(_t)
|
||||
_track_lat[_t.get("id")] = _lat
|
||||
_max_lat = max(_max_lat, _lat)
|
||||
|
||||
_channel_counter = 0
|
||||
|
||||
for track in tracks:
|
||||
@@ -347,7 +586,7 @@ class PythonRenderEngine:
|
||||
if actual_len > 0:
|
||||
track_buffer[:, start_sample:write_end] += sliced_sec[:, :actual_len]
|
||||
|
||||
# Apply Track Gain (via Pedalboard or fallback)
|
||||
# Track volume / pan / mute (spec §2 Step 3: summing gains)
|
||||
vol_db = track.get("volume_db", 0.0)
|
||||
pan = track.get("pan", 0.0)
|
||||
mute = track.get("mute", False)
|
||||
@@ -355,45 +594,29 @@ class PythonRenderEngine:
|
||||
if mute:
|
||||
continue
|
||||
|
||||
# Apply Track FX (Chorus or Reverb)
|
||||
fx_type = track.get("fx_type")
|
||||
if fx_type == "chorus":
|
||||
# Chorus: LFO delay modulation (scipy/numpy fallback — thay
|
||||
# pedalboard GPL-3.0; chất lượng thấp hơn, upgrade native DSP sau).
|
||||
# ── Track FX chain (spec §2 Step 2): serial custom FX → VST FX ──
|
||||
# Builtin DSP slots (fx_chain) → legacy fx_type (chorus/reverb) →
|
||||
# VST FX (vst_fx_chain) qua native_bridge. Mỗi slot sửa buffer
|
||||
# in-place trước khi chuyển tới slot kế — đúng Buffer_out =
|
||||
# Plugin_n(...Plugin_1(Buffer_in)).
|
||||
if track.get("fx_active", True):
|
||||
try:
|
||||
lfo = 0.020 + 0.005 * np.sin(2 * np.pi * 1.5 * np.arange(total_samples) / self.sample_rate)
|
||||
dry = track_buffer * 0.6
|
||||
wet = np.zeros_like(track_buffer)
|
||||
for ch in range(2):
|
||||
indices = np.arange(total_samples) - (lfo * self.sample_rate)
|
||||
indices = np.clip(indices, 0, total_samples - 1).astype(np.int32)
|
||||
wet[ch, :] = track_buffer[ch, indices]
|
||||
track_buffer = dry + wet * 0.5
|
||||
track_buffer = _apply_builtin_fx_chain(
|
||||
track_buffer, track.get("fx_chain"), self.sample_rate)
|
||||
except Exception as e:
|
||||
logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
|
||||
elif fx_type == "reverb":
|
||||
# Reverb: decaying-noise IR convolution (scipy — thay pedalboard GPL).
|
||||
try:
|
||||
len_ir = int(self.sample_rate * 2.0)
|
||||
t_ir = np.arange(len_ir) / self.sample_rate
|
||||
decay = np.exp(-t_ir / 0.5)
|
||||
ir_l = (np.random.rand(len_ir) * 2 - 1) * decay
|
||||
ir_r = (np.random.rand(len_ir) * 2 - 1) * decay
|
||||
logger.warning("[RenderEngine] Builtin FX chain failed: %s", e)
|
||||
fx_type = track.get("fx_type")
|
||||
if fx_type == "chorus":
|
||||
try:
|
||||
track_buffer = _apply_legacy_chorus(track_buffer, self.sample_rate)
|
||||
except Exception as e:
|
||||
logger.warning("[RenderEngine] Fallback Chorus failed: %s", e)
|
||||
elif fx_type == "reverb":
|
||||
try:
|
||||
track_buffer = _apply_legacy_reverb(track_buffer, self.sample_rate)
|
||||
except Exception as e:
|
||||
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
|
||||
|
||||
dry = track_buffer * 0.6
|
||||
wet = np.zeros_like(track_buffer)
|
||||
for ch in range(2):
|
||||
ir = ir_l if ch == 0 else ir_r
|
||||
from scipy.signal import convolve
|
||||
conv = convolve(track_buffer[ch, :], ir, mode='full')[:total_samples]
|
||||
wet[ch, :] = conv
|
||||
track_buffer = dry + wet * 0.4
|
||||
except Exception as e:
|
||||
logger.warning("[RenderEngine] Fallback Reverb failed: %s", e)
|
||||
|
||||
# Track FX chain (VST3/builtin) — FX Rack per-track: render track
|
||||
# buffer qua native_bridge --render-fx. Ưu tiên fx_chain; fx_type
|
||||
# cũ (chorus/reverb scipy) giữ làm fallback khi không có fx_chain.
|
||||
track_fx_chain = track.get("vst_fx_chain") or []
|
||||
if track_fx_chain and not track.get("vst_fx_bypass"):
|
||||
if settings.RENDER_ENGINE == "bridge":
|
||||
@@ -424,18 +647,25 @@ class PythonRenderEngine:
|
||||
else:
|
||||
logger.warning("[RenderEngine] Track FX chain cần RENDER_ENGINE=bridge — bỏ qua")
|
||||
|
||||
# Process track volume
|
||||
# Process track volume (spec §2 Step 3: Gain_i scaling)
|
||||
gain_linear = 10 ** (vol_db / 20.0)
|
||||
processed_track = track_buffer * gain_linear
|
||||
|
||||
# Apply Track Pan
|
||||
# Apply Track Pan (spec §2 Step 3: constant-power pan law)
|
||||
if pan != 0.0:
|
||||
# Constant power panning
|
||||
theta = ((np.clip(pan, -1.0, 1.0) + 1.0) / 2.0) * (np.pi / 2.0)
|
||||
processed_track[0, :] *= np.cos(theta)
|
||||
processed_track[1, :] *= np.sin(theta)
|
||||
|
||||
# Mix track to session
|
||||
# PDC (spec §3): delay track ngắn hơn (max_lat - own_lat) để mọi
|
||||
# track sum sample-accurate. latency 0 hiện tại → no-op.
|
||||
delay = _max_lat - _track_lat.get(track.get("id"), 0)
|
||||
if delay > 0:
|
||||
shifted = np.zeros_like(processed_track)
|
||||
shifted[:, delay:] = processed_track[:, :total_samples - delay]
|
||||
processed_track = shifted
|
||||
|
||||
# Mix track to session (spec §2 Step 3: linear summing)
|
||||
session_buffer += processed_track
|
||||
|
||||
return session_buffer
|
||||
@@ -494,6 +724,11 @@ class PythonRenderEngine:
|
||||
else:
|
||||
logger.warning("[RenderEngine] Masterbus FX cần RENDER_ENGINE=bridge — bỏ qua")
|
||||
|
||||
# Master bus volume (spec §2 Step 4: master gain stage after chain)
|
||||
master_vol_db = master.get("volume_db", 0.0)
|
||||
if master_vol_db:
|
||||
master_buffer *= 10 ** (master_vol_db / 20.0)
|
||||
|
||||
# Normalization to prevent clipping
|
||||
max_peak = np.max(np.abs(master_buffer))
|
||||
if max_peak > 1.0:
|
||||
|
||||
+536
-61
@@ -1541,9 +1541,10 @@ function createTrackFxModule(type, ctx, params) {
|
||||
nodes = { gLL, gRL, gLR, gRR };
|
||||
} else if (type === 'eqpro') {
|
||||
return createEqProModule(ctx, params);
|
||||
} else if (type === 'carla') {
|
||||
// Carla Bridge = VST FX chạy NGOÀI (ứng dụng ngoài, user tự cài) — trong
|
||||
// WebAudio graph chỉ là pass-through (không thêm DSP): âm track đi thẳng.
|
||||
} else if (type === 'carla' || type === 'vst3') {
|
||||
// Carla Bridge / VST FX = xử lý NGOÀI (Carla standalone / fx_vst_bridge
|
||||
// --render-fx khi export) — trong WebAudio graph chỉ là pass-through
|
||||
// (không thêm DSP): âm track đi thẳng, đúng vị trí chain.
|
||||
input.connect(output);
|
||||
nodes = { passthrough: input };
|
||||
} else {
|
||||
@@ -1567,8 +1568,12 @@ function createTrackFxModule(type, ctx, params) {
|
||||
// keyed in a LOCAL map so the live graph is never touched. Audio clips are then
|
||||
// scheduled through node.gainNode, and the offline master bus applies the
|
||||
// mastering chain — the exported file therefore matches what you hear.
|
||||
function buildOfflineTrackNode(track, ctx, nodeMap) {
|
||||
function buildOfflineTrackNode(track, ctx, nodeMap, opts) {
|
||||
if (!track || nodeMap[track.id]) return nodeMap[track.id] || null;
|
||||
// directOut: render track RIÊNG (track FX chain, chưa qua mastering bus) —
|
||||
// dùng cho export có VST FX: mỗi track render solo → áp VST chain qua
|
||||
// fx_vst_bridge → cộng mix → mastering 1 lần trên tổng.
|
||||
const directOut = !!(opts && opts.directOut);
|
||||
const gainNode = ctx.createGain();
|
||||
const volDb = track.volumeDb ?? 0;
|
||||
gainNode.gain.setValueAtTime(volDb <= -50 ? 0 : Math.pow(10, volDb / 20), 0);
|
||||
@@ -1577,10 +1582,16 @@ function buildOfflineTrackNode(track, ctx, nodeMap) {
|
||||
const analyserNode = ctx.createAnalyser();
|
||||
analyserNode.fftSize = 2048;
|
||||
pannerNode.connect(analyserNode);
|
||||
if (!masterBus) initMasterBus(ctx);
|
||||
const route = createMasteringRoute(ctx, track, masterBus);
|
||||
analyserNode.connect(route.routeGain);
|
||||
analyserNode.connect(route.dryGain);
|
||||
const node = { gainNode, pannerNode, analyserNode, route: null, fxStopFn: null, sfEntry: null, sfOut: null, sfRouteGain: null, sfDryGain: null };
|
||||
if (directOut) {
|
||||
analyserNode.connect(ctx.destination);
|
||||
} else {
|
||||
if (!masterBus) initMasterBus(ctx);
|
||||
const route = createMasteringRoute(ctx, track, masterBus);
|
||||
analyserNode.connect(route.routeGain);
|
||||
analyserNode.connect(route.dryGain);
|
||||
node.route = route;
|
||||
}
|
||||
let fxStopFn;
|
||||
const fxEntry = ctx.createGain();
|
||||
const fxLegacyIn = ctx.createGain();
|
||||
@@ -1605,7 +1616,7 @@ function buildOfflineTrackNode(track, ctx, nodeMap) {
|
||||
} else {
|
||||
fxLegacyIn.connect(pannerNode);
|
||||
}
|
||||
const node = { gainNode, pannerNode, analyserNode, route, fxStopFn, sfEntry: null, sfOut: null, sfRouteGain: null, sfDryGain: null };
|
||||
node.fxStopFn = fxStopFn || null;
|
||||
// Soundfont (MIDI cache) chain — mirrors the live node so cached MIDI buffers
|
||||
// flow through the track FX modules + mastering exactly like playback.
|
||||
if ((track.midiItems && track.midiItems.length > 0)) {
|
||||
@@ -1622,8 +1633,12 @@ function buildOfflineTrackNode(track, ctx, nodeMap) {
|
||||
sfDryGain.gain.value = sfBypass ? 1 : 0;
|
||||
sfPan.connect(sfRouteGain);
|
||||
sfPan.connect(sfDryGain);
|
||||
sfRouteGain.connect(masterBus.input);
|
||||
sfDryGain.connect(masterBus.dryInput);
|
||||
if (directOut) {
|
||||
sfPan.connect(ctx.destination);
|
||||
} else {
|
||||
sfRouteGain.connect(masterBus.input);
|
||||
sfDryGain.connect(masterBus.dryInput);
|
||||
}
|
||||
const sfMods = fxEnabled ? fxChain.filter(m => m && m.active !== false).map(m => {
|
||||
try { return createTrackFxModule(m.type || m, ctx, m.params); } catch (e) { return null; }
|
||||
}).filter(Boolean) : [];
|
||||
@@ -1639,6 +1654,187 @@ function buildOfflineTrackNode(track, ctx, nodeMap) {
|
||||
return node;
|
||||
}
|
||||
|
||||
// ── VST FX export helpers (fx_vst_bridge --render-fx qua engine /fx-render) ──
|
||||
// Track + master VST chains là pass-through trong WebAudio (live + offline
|
||||
// graph); xử lý THẬT chỉ chạy được ở export OFFLINE qua fx_vst_bridge (one-shot
|
||||
// render, không SHM realtime — xem native_bridge/src/main_fx.cpp).
|
||||
function audioBufferToInterleaved(buf) {
|
||||
const ch = buf.numberOfChannels;
|
||||
const len = buf.length;
|
||||
const mono = ch < 2;
|
||||
const out = new Float32Array(len * (mono ? 1 : 2));
|
||||
const L = buf.getChannelData(0);
|
||||
if (mono) {
|
||||
out.set(L);
|
||||
} else {
|
||||
const R = buf.getChannelData(1);
|
||||
for (let i = 0; i < len; i++) { out[i * 2] = L[i]; out[i * 2 + 1] = R[i]; }
|
||||
}
|
||||
return { pcm: out, channels: mono ? 1 : 2, sampleRate: buf.sampleRate };
|
||||
}
|
||||
function interleavedToAudioBuffer(pcm, sampleRate, channels, ctx) {
|
||||
const n = Math.floor(pcm.length / channels);
|
||||
const buf = ctx.createBuffer(channels, n, sampleRate);
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
const d = buf.getChannelData(ch);
|
||||
for (let i = 0; i < n; i++) d[i] = pcm[i * channels + ch];
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
function mixInterleaved(a, b) {
|
||||
const n = Math.max(a.length, b.length);
|
||||
const out = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
out[i] = (i < a.length ? a[i] : 0) + (i < b.length ? b[i] : 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function activeVstChain(chain) {
|
||||
return (chain || []).filter(s => s && s.path && !s.bypass);
|
||||
}
|
||||
function encodeFloatWavBlob(pcm, sampleRate, channels) {
|
||||
// IEEE float32 WAV — bit-exact input cho fx_vst_bridge (không dither/quantize
|
||||
// ở đây; final export encode giữ nguyên chính sách hiện có).
|
||||
const len = Math.floor(pcm.length / channels);
|
||||
const bps = 4;
|
||||
const dataBytes = len * bps * channels;
|
||||
const buf = new ArrayBuffer(44 + dataBytes);
|
||||
const v = new DataView(buf);
|
||||
const ws = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
|
||||
ws(0, 'RIFF'); v.setUint32(4, 36 + dataBytes, true); ws(8, 'WAVE');
|
||||
ws(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 3, true);
|
||||
v.setUint16(22, channels, true); v.setUint32(24, sampleRate, true);
|
||||
v.setUint32(28, sampleRate * bps * channels, true);
|
||||
v.setUint16(32, bps, true); v.setUint16(34, 32, true);
|
||||
ws(36, 'data'); v.setUint32(40, dataBytes, true);
|
||||
let o = 44;
|
||||
for (let i = 0; i < pcm.length; i++) v.setFloat32(o + i * 4, pcm[i], true);
|
||||
return new Blob([buf], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
// ── Realtime VST FX (SHM bridge) — master chain LIVE qua fx_vst_bridge ─────
|
||||
// --realtime-fx: engine giữ SHM ring FxRealtimeIPC, spawn bridge; browser gửi
|
||||
// block 256 stereo float32 qua WS → engine SHM → bridge xử lý → SHM → engine →
|
||||
// WS → AudioWorklet (sf-fx-realtime.js) phát. Master: masterBus.output →
|
||||
// worklet → analyser (sau master fader, trước metering/destination — khớp thứ
|
||||
// tự offline export). Track: mỗi track 1 session/worklet splice vào CẢ 2 đường
|
||||
// chain (audio-clips + MIDI/SF) — mọi luồng track qua bridge trước main out.
|
||||
const fxRt = { active: false, sessionId: null, ws: null, node: null, inQueue: [], pumpTimer: null };
|
||||
function fxRtChain() {
|
||||
const ms = window.currentMasteringSettings;
|
||||
if (!ms || !ms.masterConnected || ms.isBypassed) return [];
|
||||
const chain = activeVstChain((ms && ms.vstFxChain) || []);
|
||||
return (chain.length > 0 && !(ms && ms.vstBypass)) ? chain : [];
|
||||
}
|
||||
async function fxRtStart() {
|
||||
if (fxRt.active) {
|
||||
// masterBus bị rebuild (recovery/export) → node thuộc graph cũ → restart.
|
||||
if (!fxRt.node || fxRt.node.context !== getAudioContext()) fxRtStop(true);
|
||||
else return;
|
||||
}
|
||||
const chain = fxRtChain();
|
||||
if (!chain.length) return;
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx || !ctx.audioWorklet || !masterBus || !masterBus.output || !masterBus.analyser) return;
|
||||
const chainPayload = chain.map(s => ({ type: 'vst3', path: s.path, name: s.name || s.path, preset_b64: s.preset_b64 || '', bypass: false }));
|
||||
let st;
|
||||
try {
|
||||
st = await window.SonicAPI.fxRealtimeStart({ fx_chain: chainPayload, sample_rate: ctx.sampleRate || 44100 });
|
||||
} catch (e) {
|
||||
console.warn('[FX RT] start fail:', e);
|
||||
showToast('Không khởi động VST FX realtime: ' + (e.message || e), 'warning');
|
||||
return;
|
||||
}
|
||||
fxRt.sessionId = st.session_id;
|
||||
try {
|
||||
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608212200`);
|
||||
fxRt.node = new AudioWorkletNode(ctx, 'sf-fx-realtime', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2] });
|
||||
const u = new URL(API_BASE_URL);
|
||||
const wsProto = u.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const tok = localStorage.getItem('sonic_token') || '';
|
||||
const wsUrl = `${wsProto}${u.host}${st.ws_url}${tok ? '?token=' + encodeURIComponent(tok) : ''}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
fxRt.ws = ws;
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => { fxRt.active = true; };
|
||||
ws.onmessage = (ev) => {
|
||||
if (!(ev.data instanceof ArrayBuffer) || !fxRt.node) return;
|
||||
const f = new Float32Array(ev.data);
|
||||
const half = f.length / 2;
|
||||
const l = new Float32Array(half);
|
||||
const r = new Float32Array(half);
|
||||
for (let i = 0; i < half; i++) { l[i] = f[i * 2]; r[i] = f[i * 2 + 1]; }
|
||||
fxRt.node.port.postMessage({ type: 'out', l, r });
|
||||
};
|
||||
ws.onclose = () => fxRtStop(true);
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||||
fxRt.node.port.onmessage = (ev) => {
|
||||
const d = ev.data;
|
||||
if (!d || d.type !== 'in' || !fxRt.ws) return;
|
||||
if (fxRt.inQueue.length > 64) fxRt.inQueue.shift();
|
||||
fxRt.inQueue.push(d.data);
|
||||
};
|
||||
// Pace input: tối đa 1 block/kỳ (~5.8ms). Gửi dồn theo burst (main thread bị
|
||||
// rAF renderFrame block khi mở MASTERING PANEL) → SHM ring 4-slot tràn → mất
|
||||
// block → ngắt quãng.
|
||||
const blockMs = Math.max(4, Math.round((256 / (ctx.sampleRate || 44100)) * 1000));
|
||||
if (fxRt.pumpTimer) clearInterval(fxRt.pumpTimer);
|
||||
fxRt.pumpTimer = setInterval(() => {
|
||||
const ws = fxRt.ws;
|
||||
if (!ws || ws.readyState !== 1 || !fxRt.inQueue.length) return;
|
||||
const d = fxRt.inQueue.shift();
|
||||
try { ws.send(d); } catch (e) { fxRt.inQueue.unshift(d); }
|
||||
}, blockMs);
|
||||
try { masterBus.output.disconnect(masterBus.analyser); } catch (e) {}
|
||||
masterBus.output.connect(fxRt.node);
|
||||
fxRt.node.connect(masterBus.analyser);
|
||||
} catch (e) {
|
||||
console.warn('[FX RT] setup fail:', e);
|
||||
fxRtStop(true);
|
||||
}
|
||||
}
|
||||
function fxRtStop(silent) {
|
||||
if (fxRt.pumpTimer) { clearInterval(fxRt.pumpTimer); fxRt.pumpTimer = null; }
|
||||
fxRt.inQueue = [];
|
||||
if (!fxRt.active && !fxRt.ws && !fxRt.node && !fxRt.sessionId) return;
|
||||
try {
|
||||
if (fxRt.node) {
|
||||
fxRt.node.disconnect();
|
||||
if (masterBus && masterBus.output && masterBus.analyser) {
|
||||
try { masterBus.output.connect(masterBus.analyser); } catch (e) {}
|
||||
}
|
||||
fxRt.node.port.onmessage = null;
|
||||
fxRt.node = null;
|
||||
}
|
||||
} catch (e) {}
|
||||
try { if (fxRt.ws) { fxRt.ws.onclose = null; fxRt.ws.onmessage = null; try { fxRt.ws.close(); } catch (e) {} fxRt.ws = null; } } catch (e) {}
|
||||
const sid = fxRt.sessionId;
|
||||
fxRt.sessionId = null;
|
||||
fxRt.active = false;
|
||||
if (sid) {
|
||||
try { window.SonicAPI.fxRealtimeStop({ session_id: sid }).catch(() => {}); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Realtime VST FX per-track (SHM bridge) — mọi luồng track qua bridge ─────
|
||||
// Mỗi track: session riêng (1 WS/SHM) cho TỪNG đường chain có âm — audio-clips
|
||||
// (path 'audio', splice fxEntry→mods→worklet→fxLegacyIn) và MIDI/SF (path 'sf',
|
||||
// splice sfEntry→mods→worklet→sfOut). Track vừa clips vừa MIDI → 2 session
|
||||
// cùng chain (khớp app: builtin FX cũng chạy instance riêng mỗi path; offline
|
||||
// export áp VST lên track mix tổng). Thứ tự: builtin FX → VST (khớp offline).
|
||||
const fxRtTracks = {}; // 'trackId:audio|sf' → { sessionId, ws, node, ready, path, spliced, chainKey }
|
||||
function fxRtTrackChain(track) {
|
||||
if (!track) return [];
|
||||
const chain = activeVstChain(track.vstFxChain || []);
|
||||
return (chain.length > 0 && !track.vstFxBypass) ? chain : [];
|
||||
}
|
||||
function fxRtTrackPaths(track) {
|
||||
const paths = [];
|
||||
if (track && ((track.clips && track.clips.length > 0) || !!track.buffer || (track.sections && track.sections.length > 0))) paths.push('audio');
|
||||
if (track && ((track.midiItems && track.midiItems.length > 0) || track.type === 'MIDI' || !!track.instrumentId)) paths.push('sf');
|
||||
return paths;
|
||||
}
|
||||
|
||||
// Track-EQ preset library (unified_fx_rack_panel.md §III.1) — band gains only.
|
||||
const TRACK_EQ_PRESETS = {
|
||||
flat: { name: 'Flat / Reset', g: [0, 0, 0, 0] },
|
||||
@@ -2268,7 +2464,6 @@ const MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => {
|
||||
|
||||
// ── Master Strip Console Component (from md/47_MASTER_STRIP_CONSOLE.md) ──
|
||||
const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal, setShowMasteringModal, masteringSettings, setMasteringSettings, isPlaying }) => {
|
||||
const [isFxActive, setIsFxActive] = React.useState(true);
|
||||
const [isTestPlaying, setIsTestPlaying] = React.useState(false);
|
||||
const [isMuted, setIsMuted] = React.useState(false);
|
||||
const [isMono, setIsMono] = React.useState(false);
|
||||
@@ -2535,7 +2730,7 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal,
|
||||
}
|
||||
},
|
||||
title: "Bật/Tắt MASTER FX CHAIN Bypass"
|
||||
}, [React.createElement("i", { className: "fa-solid fa-power-off" + (isFxActive ? " text-emerald-400" : " text-slate-500"), key: "ico" }), React.createElement("span", { key: "lbl", className: "text-[7px] font-bold" + (isFxActive ? " text-emerald-300" : " text-slate-400") }, "PWR")]),
|
||||
}, [React.createElement("i", { className: "fa-solid fa-power-off" + (isMasterActive ? " text-emerald-400" : " text-slate-500"), key: "ico" }), React.createElement("span", { key: "lbl", className: "text-[7px] font-bold" + (isMasterActive ? " text-emerald-300" : " text-slate-400") }, "PWR")]),
|
||||
React.createElement("button", { className: "btn-daw h-[18px] rounded text-slate-400 text-[8px]", title: "Trim Envelope" }, "TRIM"),
|
||||
React.createElement("button", { className: "btn-daw h-[18px] rounded text-slate-400 text-[9px]", title: "Session Info" },
|
||||
React.createElement("i", { className: "fa-solid fa-info" })
|
||||
@@ -2742,7 +2937,7 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs, style })
|
||||
if (onUpdateTrack) onUpdateTrack(track.id, { fxActive: next });
|
||||
// Live re-route: rebuild both FX chains (audio + SF) so EVERY item
|
||||
// (midi/section/audioclip) immediately follows the FX power state.
|
||||
if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id);
|
||||
if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id, { fxActive: next });
|
||||
},
|
||||
className: "btn-daw h-[24px] rounded flex items-center justify-center text-[8px]" + (track.fxActive !== false ? " text-emerald-400" : " text-slate-500"),
|
||||
title: "Toggle FX Power: ON (sáng) = MỌI items (MIDI/section/audioclip) qua FX Rack Panel (nút A/♪ chỉ ảnh hưởng Mastering FX Chain); OFF (tối) = bỏ toàn bộ track FX"
|
||||
@@ -11296,7 +11491,7 @@ const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsLi
|
||||
fx_chain: masteringSettings.vstFxChain.map(s => ({ type: s.type || 'vst3', path: s.path || '', name: s.name || '', preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '', bypass: !!s.bypass })),
|
||||
volume_db: (typeof masterVolume === 'number' && isFinite(masterVolume)) ? masterVolume : 0,
|
||||
mute: false,
|
||||
bypass: !!masteringSettings.vstBypass
|
||||
bypass: !masteringSettings.masterConnected || !!masteringSettings.vstBypass
|
||||
}
|
||||
} : {})
|
||||
},
|
||||
@@ -12192,7 +12387,7 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
|
||||
...chain.map((m, i) => ({ kind: 'builtin', m, i })),
|
||||
...vstChain.map((s, i) => ({ kind: 'vst', s, i })),
|
||||
];
|
||||
const setVstChain = (next) => { if (onUpdateTrack) onUpdateTrack(track.id, { vstFxChain: next, vstFxBypass: vstBypass }); };
|
||||
const setVstChain = (next) => { if (onUpdateTrack) onUpdateTrack(track.id, { vstFxChain: next, vstFxBypass: vstBypass }); if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id, { vstFxChain: next, vstFxBypass: vstBypass }); };
|
||||
// Embedded VST GUI state (PLAN_MASTERBUS_VST_GUI_EMBED.md Phase 3)
|
||||
const [fxGuiOpen, setFxGuiOpenState] = React.useState(_persistFxGuiOpen); // { path, url } | null
|
||||
const setFxGuiOpen = (v) => { _persistFxGuiOpen = v; _persistFxGuiTrackId = v ? (track && track.id) : null; setFxGuiOpenState(v); };
|
||||
@@ -12262,7 +12457,7 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
|
||||
|
||||
const setChain = (next) => {
|
||||
if (onUpdateTrack) onUpdateTrack(track.id, { fxChain: next });
|
||||
if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id);
|
||||
if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id, { fxChain: next });
|
||||
};
|
||||
const paramsOf = (m) => ({ ...(TRACK_FX_DEFAULTS[m.type] || {}), ...(m.params || {}) });
|
||||
const setParams = (idx, patch) => {
|
||||
@@ -12564,13 +12759,13 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
|
||||
|
||||
{/* VST FX toolbar — VST3 (Ozone...) áp dụng lúc Export/Offline render (preview = âm export) */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2 shrink-0">
|
||||
<span className="text-[9px] text-slate-500 font-mono truncate">VST3 (Ozone...) — realtime browser vẫn WebAudio builtin; VST áp dụng lúc Export/Offline render</span>
|
||||
<span className="text-[9px] text-slate-500 font-mono truncate">VST3 — realtime qua bridge (SHM) khi play + áp dụng lúc Export/Offline render</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={previewFx} className="px-2 py-1 bg-teal-800 hover:bg-teal-700 text-teal-100 text-[10px] font-semibold rounded transition flex items-center gap-1">
|
||||
<i data-lucide="play" className="w-3 h-3"></i> Nghe thử (render offline)
|
||||
</button>
|
||||
<label className="flex items-center gap-1 text-[10px] text-slate-400 font-mono cursor-pointer">
|
||||
<input type="checkbox" checked={vstBypass} onChange={e => { const val = e.target.checked; setVstBypass(val); if (onUpdateTrack) onUpdateTrack(track.id, { vstFxBypass: val }); }} className="w-3 h-3" /> Bypass
|
||||
<input type="checkbox" checked={vstBypass} onChange={e => { const val = e.target.checked; setVstBypass(val); if (onUpdateTrack) onUpdateTrack(track.id, { vstFxBypass: val }); if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id, { vstFxBypass: val }); }} className="w-3 h-3" /> Bypass
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18552,6 +18747,12 @@ const App = () => {
|
||||
if (audioCtx && masterBus) {
|
||||
toggleMasteringOnMaster(masteringSettings.masterConnected, masteringSettings.isBypassed);
|
||||
applyMasteringSettings(masteringSettings);
|
||||
// Đồng bộ realtime VST master với PWR/Bypass: WebAudio graph đã đổi theo
|
||||
// masterConnected nhưng fxRt không tự dừng → tắt PWR mà âm không đổi.
|
||||
try {
|
||||
if (fxRtChain().length) { if (!fxRt.active) fxRtStart(); }
|
||||
else if (fxRt.active) { fxRtStop(); }
|
||||
} catch (e) { console.warn('[FX RT] resync fail:', e); }
|
||||
// Re-sync live track routes: mastering ON → mọi track qua chain (♪ bị
|
||||
// override bởi effMidiBypass/effAudioBypass) — nút PWR bật/tắt phải áp
|
||||
// ngay lên node đang phát (không chờ tracks effect).
|
||||
@@ -23094,13 +23295,14 @@ const App = () => {
|
||||
// re-routes ONLY this track's fx chain (fxEntry → active modules → fxLegacyIn),
|
||||
// leaving other tracks and the master bus untouched. Called by the FX Rack
|
||||
// panel on add/remove/reorder/toggle/param change — live while playing.
|
||||
const rebuildTrackFxGraph = (trackId) => {
|
||||
const rebuildTrackFxGraph = (trackId, patch) => {
|
||||
const node = activeTrackNodesRef.current[trackId];
|
||||
if (!node || !node.fxEntry || !node.fxLegacyIn) return;
|
||||
try {
|
||||
const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks;
|
||||
const track = list.find(t => t.id === trackId);
|
||||
let track = list.find(t => t.id === trackId);
|
||||
if (!track) return;
|
||||
if (patch) track = { ...track, ...patch };
|
||||
node.fxEntry.disconnect();
|
||||
const fxEnabled = track.fxActive !== false;
|
||||
const chainMods = fxEnabled ? (track.fxChain || []).filter(m => m && m.active !== false).map(m => {
|
||||
@@ -23130,6 +23332,10 @@ const App = () => {
|
||||
if (node.sfPan && getAudioContext()) {
|
||||
node.sfPan.pan.setTargetAtTime((track.pan ?? 0) / 100, getAudioContext().currentTime, 0.02);
|
||||
}
|
||||
// Realtime VST FX per-track: chain mới → reconcile session/worklet
|
||||
// (start nếu chain active khi play / stop nếu chain bị bypass / re-splice
|
||||
// nếu mods đổi). VST áp SAU builtin FX — khớp offline export.
|
||||
fxRtTrackReconcile(trackId, node, track);
|
||||
} catch (e) {
|
||||
console.warn('rebuildTrackFxGraph error:', e);
|
||||
}
|
||||
@@ -23138,6 +23344,143 @@ const App = () => {
|
||||
rebuildTrackFxGraphRef.current = rebuildTrackFxGraph;
|
||||
window.__rebuildTrackFxGraph = rebuildTrackFxGraph;
|
||||
|
||||
// ── Realtime VST FX per-track (SHM bridge) — component scope ──────────────
|
||||
// Cần activeTrackNodesRef/isPlayingRef. Session giống master (fxRtStart):
|
||||
// fetch start → AudioWorkletNode 'sf-fx-realtime' → WS; WS open → splice
|
||||
// worklet vào chain hiện tại (đọc mods MỚI NHẤT — an toàn với rebuild).
|
||||
const fxRtTrackStart = async (trackId, track, path) => {
|
||||
const key = trackId + ':' + path;
|
||||
const chain = fxRtTrackChain(track);
|
||||
if (!chain.length) return;
|
||||
if (fxRtTracks[key]) {
|
||||
if (!fxRtTracks[key].node || fxRtTracks[key].node.context !== getAudioContext()) fxRtTrackStop(trackId, path, true);
|
||||
else return;
|
||||
}
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx || !ctx.audioWorklet) return;
|
||||
const chainPayload = chain.map(s => ({ type: 'vst3', path: s.path, name: s.name || s.path, preset_b64: s.preset_b64 || '', bypass: false }));
|
||||
let st;
|
||||
try {
|
||||
st = await window.SonicAPI.fxRealtimeStart({ fx_chain: chainPayload, sample_rate: ctx.sampleRate || 44100 });
|
||||
} catch (e) {
|
||||
console.warn('[FX RT track] start fail', trackId, path, e);
|
||||
return;
|
||||
}
|
||||
const entry = { sessionId: st.session_id, ws: null, node: null, ready: false, path, spliced: null, chainKey: JSON.stringify(chainPayload), inQueue: [], pumpTimer: null };
|
||||
fxRtTracks[key] = entry;
|
||||
try {
|
||||
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608212200`);
|
||||
const node = new AudioWorkletNode(ctx, 'sf-fx-realtime', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2] });
|
||||
entry.node = node;
|
||||
const u = new URL(API_BASE_URL);
|
||||
const wsProto = u.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const tok = localStorage.getItem('sonic_token') || '';
|
||||
const ws = new WebSocket(`${wsProto}${u.host}${st.ws_url}${tok ? '?token=' + encodeURIComponent(tok) : ''}`);
|
||||
entry.ws = ws;
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => { entry.ready = true; fxRtTrackSplice(trackId, path); };
|
||||
ws.onmessage = (ev) => {
|
||||
if (!(ev.data instanceof ArrayBuffer) || !entry.node) return;
|
||||
const f = new Float32Array(ev.data);
|
||||
const half = f.length / 2;
|
||||
const l = new Float32Array(half);
|
||||
const r = new Float32Array(half);
|
||||
for (let i = 0; i < half; i++) { l[i] = f[i * 2]; r[i] = f[i * 2 + 1]; }
|
||||
entry.node.port.postMessage({ type: 'out', l, r });
|
||||
};
|
||||
ws.onclose = () => fxRtTrackStop(trackId, path, true);
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||||
entry.node.port.onmessage = (ev) => {
|
||||
const d = ev.data;
|
||||
if (!d || d.type !== 'in' || !entry.ws) return;
|
||||
if (entry.inQueue.length > 64) entry.inQueue.shift();
|
||||
entry.inQueue.push(d.data);
|
||||
};
|
||||
const blockMs = Math.max(4, Math.round((256 / (ctx.sampleRate || 44100)) * 1000));
|
||||
entry.pumpTimer = setInterval(() => {
|
||||
const ws = entry.ws;
|
||||
if (!ws || ws.readyState !== 1 || !entry.inQueue.length) return;
|
||||
const d = entry.inQueue.shift();
|
||||
try { ws.send(d); } catch (e) { entry.inQueue.unshift(d); }
|
||||
}, blockMs);
|
||||
} catch (e) {
|
||||
console.warn('[FX RT track] setup fail', trackId, path, e);
|
||||
fxRtTrackStop(trackId, path, true);
|
||||
}
|
||||
};
|
||||
const fxRtTrackUnsplice = (trackId, path) => {
|
||||
const entry = fxRtTracks[trackId + ':' + path];
|
||||
if (!entry || !entry.spliced || !entry.node) return;
|
||||
try {
|
||||
entry.node.disconnect();
|
||||
try { entry.spliced.tail.connect(entry.spliced.dest); } catch (e) {}
|
||||
entry.spliced = null;
|
||||
} catch (e) { console.warn('[FX RT track] unsplice fail', trackId, path, e); }
|
||||
};
|
||||
const fxRtTrackSplice = (trackId, path) => {
|
||||
const key = trackId + ':' + path;
|
||||
const entry = fxRtTracks[key];
|
||||
const node = activeTrackNodesRef.current[trackId];
|
||||
if (!entry || !entry.ready || !entry.node || !node || entry.spliced) return;
|
||||
const tail = path === 'audio'
|
||||
? (node.fxMods && node.fxMods.length ? node.fxMods[node.fxMods.length - 1].output : node.fxEntry)
|
||||
: (node.sfMods && node.sfMods.length ? node.sfMods[node.sfMods.length - 1].output : node.sfEntry);
|
||||
const dest = path === 'audio' ? node.fxLegacyIn : node.sfOut;
|
||||
if (!tail || !dest) return;
|
||||
try {
|
||||
tail.disconnect(dest);
|
||||
entry.node.connect(dest);
|
||||
tail.connect(entry.node);
|
||||
entry.spliced = { tail, dest };
|
||||
console.log('[FX RT track] spliced', trackId, path);
|
||||
} catch (e) { console.warn('[FX RT track] splice fail', trackId, path, e); }
|
||||
};
|
||||
const fxRtTrackStop = (trackId, path, silent) => {
|
||||
const key = trackId + ':' + path;
|
||||
const entry = fxRtTracks[key];
|
||||
if (!entry) return;
|
||||
if (entry.pumpTimer) { clearInterval(entry.pumpTimer); entry.pumpTimer = null; }
|
||||
entry.inQueue = [];
|
||||
try {
|
||||
fxRtTrackUnsplice(trackId, path);
|
||||
if (entry.node) { entry.node.port.onmessage = null; try { entry.node.disconnect(); } catch (e) {} entry.node = null; }
|
||||
} catch (e) {}
|
||||
try { if (entry.ws) { entry.ws.onclose = null; entry.ws.onmessage = null; try { entry.ws.close(); } catch (e) {} entry.ws = null; } } catch (e) {}
|
||||
const sid = entry.sessionId;
|
||||
delete fxRtTracks[key];
|
||||
if (sid) {
|
||||
try { window.SonicAPI.fxRealtimeStop({ session_id: sid }).catch(() => {}); } catch (e) {}
|
||||
}
|
||||
};
|
||||
const fxRtTrackReconcile = (trackId, node, track) => {
|
||||
try {
|
||||
const chain = fxRtTrackChain(track);
|
||||
const wantPaths = chain.length ? fxRtTrackPaths(track) : [];
|
||||
const prefix = trackId + ':';
|
||||
Object.keys(fxRtTracks).forEach(k => {
|
||||
if (!k.startsWith(prefix)) return;
|
||||
const p = k.slice(prefix.length);
|
||||
if (wantPaths.indexOf(p) < 0) fxRtTrackStop(trackId, p, true);
|
||||
});
|
||||
if (!isPlayingRef.current) return;
|
||||
wantPaths.forEach(p => {
|
||||
const key = trackId + ':' + p;
|
||||
const entry = fxRtTracks[key];
|
||||
if (!entry) fxRtTrackStart(trackId, track, p);
|
||||
else if (entry.ready && node) {
|
||||
fxRtTrackUnsplice(trackId, p); // chain mới (sau rebuild) — splice lại đúng mods
|
||||
fxRtTrackSplice(trackId, p);
|
||||
}
|
||||
});
|
||||
} catch (e) { console.warn('fxRtTrackReconcile error:', e); }
|
||||
};
|
||||
const fxRtTrackStopAll = () => {
|
||||
Object.keys(fxRtTracks).forEach(k => {
|
||||
const i = k.lastIndexOf(':');
|
||||
fxRtTrackStop(k.slice(0, i), k.slice(i + 1), true);
|
||||
});
|
||||
};
|
||||
|
||||
// Route the SHARED FluidSynth output through the gainNode of the single
|
||||
// audible MIDI track so its FX Rack chain / fader / pan affect the soundfont
|
||||
// instrument (unified_fx_rack_panel.md + user request). With more than one
|
||||
@@ -23546,6 +23889,8 @@ const App = () => {
|
||||
const context = getAudioContext();
|
||||
_nativePlayEpoch++; // new play — vô hiệu doHardStop đang fade (play trong cửa sổ 80ms)
|
||||
restorePendingFadeSession(); // V17: huy fade dang treo — replay khong bi dip gain
|
||||
// Realtime VST FX (master chain qua SHM bridge): bật khi play + chain active.
|
||||
try { fxRtStart(); } catch (e) { console.warn('[FX RT] start error:', e); }
|
||||
// D2: bridge active -> báo transport PLAY (bridge flush note-off cũ + đồng
|
||||
// bộ timeline; A13 C++ xử lý arg1=playheadSamples sau này).
|
||||
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) {
|
||||
@@ -23578,6 +23923,14 @@ const App = () => {
|
||||
if (!isPlayable) return;
|
||||
|
||||
const gainNode = getOrCreateTrackNode(track, context);
|
||||
// Realtime VST FX per-track (SHM bridge): mọi luồng track qua bridge —
|
||||
// session/worklet cho TỪNG path có âm (audio-clips + MIDI/SF); splice
|
||||
// vào graph khi WS ready.
|
||||
try {
|
||||
const rtPaths = fxRtTrackPaths(track);
|
||||
if (rtPaths.indexOf('audio') >= 0) fxRtTrackStart(track.id, track, 'audio');
|
||||
if (rtPaths.indexOf('sf') >= 0) fxRtTrackStart(track.id, track, 'sf');
|
||||
} catch (e) { console.warn('[FX RT track] start error:', e); }
|
||||
const pannerNode = activeTrackNodesRef.current[track.id].pannerNode;
|
||||
// Re-apply route gains theo content-type NGAY tại thời điểm play — node
|
||||
// có thể STALE (tạo từ MAIN play với track cùng id — route theo data cũ)
|
||||
@@ -24203,6 +24556,10 @@ const App = () => {
|
||||
const stopAllPlayback = (fade) => {
|
||||
try {
|
||||
isPlayingRef.current = false;
|
||||
// Realtime VST FX: dừng session + khôi phục output→analyser.
|
||||
try { fxRtStop(); } catch (e) { console.warn('[FX RT] stop error:', e); }
|
||||
// Realtime VST FX per-track: dừng mọi session track + restore chain trực tiếp.
|
||||
try { fxRtTrackStopAll(); } catch (e) { console.warn('[FX RT track] stop error:', e); }
|
||||
_nativePlayEpoch++; // hủy mọi render/decode pending của lần play cũ
|
||||
// V11: hủy mọi note timer của lần play cũ (stop/seek/loop restart) — timer
|
||||
// cũ bắn vào vòng mới = NOTE_OFF cắt note đang kêu → crack (xem ref).
|
||||
@@ -26936,9 +27293,24 @@ const App = () => {
|
||||
else if (m.startTime) needDur = Math.max(needDur, m.startTime + (m.duration || 8));
|
||||
});
|
||||
});
|
||||
// Cache có thể "đủ" (chunks + duration) nhưng RỖNG — khi bridge VSTi active,
|
||||
// âm đi qua BridgeAudioNode, KHÔNG qua track.sfEntry nơi ensureMidiCapture
|
||||
// tap → chunks toàn zero → export offline ra file câm tuyệt đối. Kiểm tra
|
||||
// năng lượng cache: nếu không có tín hiệu → bounce realtime (bắt đúng âm bridge).
|
||||
const cacheHasEnergy = (c) => {
|
||||
if (!c) return false;
|
||||
const chunks = c.chunks || [];
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const ch = chunks[i];
|
||||
for (let j = 0; j < ch.length; j++) {
|
||||
if (Math.abs(ch[j]) > 0.0005) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const cacheOK = midiTracks.length > 0 && midiTracks.every(t => {
|
||||
const c = midiCacheRef.current[t.id];
|
||||
return c && c.chunks.length > 0 && c.duration >= needDur - 0.3;
|
||||
return c && c.chunks.length > 0 && c.duration >= needDur - 0.3 && cacheHasEnergy(c);
|
||||
});
|
||||
if (cacheOK) {
|
||||
showToast("Dùng MIDI cache từ preview — export OFFLINE NHANH (vẫn đủ FX Rack + Mastering Chain).", "info");
|
||||
@@ -27417,6 +27789,75 @@ const App = () => {
|
||||
if (c && !loopActive && c.duration > durationLimit) durationLimit = c.duration;
|
||||
});
|
||||
const outChannels = exportSettings.channels === 'mono' ? 1 : 2;
|
||||
const scheduleTrackInto = (t, ctx, nodes, directOut) => {
|
||||
const node = buildOfflineTrackNode(t, ctx, nodes, { directOut });
|
||||
if (!node) return null;
|
||||
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
|
||||
id: 'default',
|
||||
buffer: t.buffer,
|
||||
startTime: t.startTime || 0,
|
||||
name: t.name,
|
||||
speed: t.speed || 1.0
|
||||
}] : [];
|
||||
clips.forEach(clip => {
|
||||
if (!clip.buffer) return;
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = clip.buffer;
|
||||
source.playbackRate.value = clip.speed || 1.0;
|
||||
source.connect(node.gainNode);
|
||||
const clipStart = clip.startTime || 0;
|
||||
source.start(clipStart);
|
||||
});
|
||||
// MIDI preview cache → schedule through the SF chain (track FX + mastering)
|
||||
if (node.sfEntry) {
|
||||
const midiBuf = midiCacheToBuffer(t.id);
|
||||
if (midiBuf) {
|
||||
let mSrcBuf = midiBuf;
|
||||
if (loopInfo && loopInfo.loopDur) {
|
||||
const ln = Math.min(midiBuf.length, Math.max(1, Math.ceil(loopInfo.loopDur * midiBuf.sampleRate)));
|
||||
mSrcBuf = ctx.createBuffer(midiBuf.numberOfChannels, ln, midiBuf.sampleRate);
|
||||
for (let mc = 0; mc < midiBuf.numberOfChannels; mc++) mSrcBuf.copyToChannel(midiBuf.getChannelData(mc).subarray(0, ln), mc);
|
||||
}
|
||||
const mSrc = ctx.createBufferSource();
|
||||
mSrc.buffer = mSrcBuf;
|
||||
mSrc.connect(node.sfEntry);
|
||||
mSrc.start(0);
|
||||
}
|
||||
}
|
||||
return node;
|
||||
};
|
||||
// VST FX THẬT qua fx_vst_bridge (engine /fx-render): encode WAV float32 →
|
||||
// upload → bridge render → download → decode. Lỗi → giữ nguyên PCM (export
|
||||
// vẫn chạy, toast cảnh báo) — không chặn export.
|
||||
const applyVstChainToPcm = async (pcm, rate, chain, label) => {
|
||||
const active = activeVstChain(chain);
|
||||
if (!active.length) return pcm;
|
||||
try {
|
||||
const blob = encodeFloatWavBlob(pcm, rate, 2);
|
||||
const fd = new FormData();
|
||||
fd.append('file', blob, 'vst_fx_input.wav');
|
||||
const up = await fetch(`${API_AUDIO}/upload`, { method: 'POST', body: fd });
|
||||
if (!up.ok) throw new Error('upload VST input thất bại (HTTP ' + up.status + ')');
|
||||
const upData = await up.json();
|
||||
const fxChain = active.map(s => ({ type: 'vst3', path: s.path, name: s.name || s.path, preset_b64: s.preset_b64 || '', bypass: false }));
|
||||
const r = await window.SonicAPI.fxRender({ input_file_id: upData.file_id, fx_chain: fxChain, sample_rate: rate });
|
||||
if (!r || !r.url) throw new Error('fx-render không trả url');
|
||||
const resp = await fetch(`${API_BASE_URL}${r.url}`);
|
||||
if (!resp.ok) throw new Error('download VST output thất bại (HTTP ' + resp.status + ')');
|
||||
const ab = await resp.arrayBuffer();
|
||||
const dctx = new OfflineAudioContext(2, 1, rate);
|
||||
const audioBuf = await dctx.decodeAudioData(ab);
|
||||
try { if (window.SonicAPI.deleteMyFile) window.SonicAPI.deleteMyFile(upData.file_id); } catch (e) { }
|
||||
return audioBufferToInterleaved(audioBuf).pcm;
|
||||
} catch (e) {
|
||||
console.warn('[VST FX]', label, 'không áp dụng:', e);
|
||||
showToast(`VST FX "${label}" không áp dụng được: ${e.message || e} — export không có VST này.`, 'warning');
|
||||
return pcm;
|
||||
}
|
||||
};
|
||||
const trackVstChains = activeTracks.filter(t => activeVstChain(t.vstFxChain || []).length > 0);
|
||||
const masterVstChain = activeVstChain((masteringSettings && masteringSettings.vstFxChain) || []);
|
||||
const masterVstOn = masterVstChain.length > 0 && !(masteringSettings && masteringSettings.vstBypass);
|
||||
const offlineCtx = new OfflineAudioContext(outChannels, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate);
|
||||
// Build the FULL graph (per-track FX Rack chains + mastering chain — the
|
||||
// same DSP as playback), so the exported file matches what you hear.
|
||||
@@ -27439,43 +27880,60 @@ const App = () => {
|
||||
masterBus.output.gain.setValueAtTime(mv <= -50 ? 0 : Math.pow(10, mv / 20), 0);
|
||||
}
|
||||
} catch (e) { console.warn('offline main-out volume error:', e); }
|
||||
activeTracks.forEach(t => {
|
||||
const node = buildOfflineTrackNode(t, offlineCtx, offlineNodes);
|
||||
if (!node) return;
|
||||
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
|
||||
id: 'default',
|
||||
buffer: t.buffer,
|
||||
startTime: t.startTime || 0,
|
||||
name: t.name,
|
||||
speed: t.speed || 1.0
|
||||
}] : [];
|
||||
clips.forEach(clip => {
|
||||
if (!clip.buffer) return;
|
||||
const source = offlineCtx.createBufferSource();
|
||||
source.buffer = clip.buffer;
|
||||
source.playbackRate.value = clip.speed || 1.0;
|
||||
source.connect(node.gainNode);
|
||||
const clipStart = clip.startTime || 0;
|
||||
source.start(clipStart);
|
||||
});
|
||||
// MIDI preview cache → schedule through the SF chain (track FX + mastering)
|
||||
if (node.sfEntry) {
|
||||
const midiBuf = midiCacheToBuffer(t.id);
|
||||
if (midiBuf) {
|
||||
let mSrcBuf = midiBuf;
|
||||
if (loopInfo && loopInfo.loopDur) {
|
||||
const ln = Math.min(midiBuf.length, Math.max(1, Math.ceil(loopInfo.loopDur * midiBuf.sampleRate)));
|
||||
mSrcBuf = offlineCtx.createBuffer(midiBuf.numberOfChannels, ln, midiBuf.sampleRate);
|
||||
for (let mc = 0; mc < midiBuf.numberOfChannels; mc++) mSrcBuf.copyToChannel(midiBuf.getChannelData(mc).subarray(0, ln), mc);
|
||||
}
|
||||
const mSrc = offlineCtx.createBufferSource();
|
||||
mSrc.buffer = mSrcBuf;
|
||||
mSrc.connect(node.sfEntry);
|
||||
mSrc.start(0);
|
||||
}
|
||||
let renderedBuffer;
|
||||
if (trackVstChains.length > 0 || masterVstOn) {
|
||||
// Export có VST FX → render TRACK RIÊNG (không mastering) → áp track
|
||||
// VST chain qua fx_vst_bridge → cộng mix → mastering built-in 1 lần
|
||||
// trên tổng → master VST chain cuối chain. Đúng thứ tự live: track fx
|
||||
// → (sum) → master fx → out.
|
||||
let mix = null;
|
||||
for (const t of activeTracks) {
|
||||
const chain = activeVstChain(t.vstFxChain || []);
|
||||
const tctx = new OfflineAudioContext(2, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate);
|
||||
const tNodes = {};
|
||||
const node = scheduleTrackInto(t, tctx, tNodes, true);
|
||||
if (!node) continue;
|
||||
const tb = await tctx.startRendering();
|
||||
try { if (node.fxStopFn) node.fxStopFn(); } catch (e) { }
|
||||
let pcm = audioBufferToInterleaved(tb).pcm;
|
||||
if (chain.length) pcm = await applyVstChainToPcm(pcm, targetRate, chain, t.name || 'track');
|
||||
mix = mix ? mixInterleaved(mix, pcm) : pcm;
|
||||
}
|
||||
});
|
||||
const renderedBuffer = await offlineCtx.startRendering();
|
||||
if (!mix) mix = new Float32Array(Math.ceil(targetRate * Math.max(0.1, durationLimit)) * 2);
|
||||
const mctx = new OfflineAudioContext(2, Math.floor(mix.length / 2), targetRate);
|
||||
masterBus = null;
|
||||
initMasterBus(mctx);
|
||||
try {
|
||||
toggleMasteringOnMaster(!!(masteringSettings && masteringSettings.masterConnected), !!(masteringSettings && masteringSettings.isBypassed));
|
||||
} catch (e) { console.warn('offline mastering toggle error (vst):', e); }
|
||||
try {
|
||||
if (masterBus && masterBus.output) {
|
||||
const mv = (typeof masterVolume === 'number' && isFinite(masterVolume)) ? masterVolume : 0;
|
||||
masterBus.output.gain.setValueAtTime(mv <= -50 ? 0 : Math.pow(10, mv / 20), 0);
|
||||
}
|
||||
} catch (e) { console.warn('offline main-out volume error (vst):', e); }
|
||||
const src = mctx.createBufferSource();
|
||||
src.buffer = interleavedToAudioBuffer(mix, targetRate, 2, mctx);
|
||||
src.connect(masterBus.input);
|
||||
src.start(0);
|
||||
renderedBuffer = await mctx.startRendering();
|
||||
if (masterVstOn) {
|
||||
const conv = audioBufferToInterleaved(renderedBuffer);
|
||||
let outPcm = await applyVstChainToPcm(conv.pcm, targetRate, masterVstChain, 'master');
|
||||
if (outChannels === 1) {
|
||||
const n = Math.floor(outPcm.length / 2);
|
||||
const mono = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) mono[i] = (outPcm[i * 2] + outPcm[i * 2 + 1]) * 0.5;
|
||||
outPcm = mono;
|
||||
}
|
||||
renderedBuffer = interleavedToAudioBuffer(outPcm, targetRate, outChannels, mctx);
|
||||
}
|
||||
} else {
|
||||
activeTracks.forEach(t => {
|
||||
scheduleTrackInto(t, offlineCtx, offlineNodes, false);
|
||||
});
|
||||
renderedBuffer = await offlineCtx.startRendering();
|
||||
}
|
||||
const numExportCh = renderedBuffer.numberOfChannels;
|
||||
const exportLength = renderedBuffer.length;
|
||||
const bytesPerSample = bitDepth / 8;
|
||||
@@ -27590,9 +28048,26 @@ const App = () => {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Restore the live master bus + live mastering state after offline render.
|
||||
masterBus = savedMasterBus;
|
||||
try { if (savedMasterBus) toggleMasteringOnMaster(!!(masteringSettings && masteringSettings.masterConnected), !!(masteringSettings && masteringSettings.isBypassed)); } catch (e) { }
|
||||
// Restore the LIVE master bus after offline render: initMasterBus(offlineCtx)
|
||||
// ghi đè cả `masterBus` lẫn `window.masterBus` bằng graph OfflineAudioContext
|
||||
// (state closed) → track route (window.masterBus.input) đổ vào context câm →
|
||||
// play/bounce sau export CÂM. Khôi phục bus live (giữ wiring cũ nếu còn sống,
|
||||
// ngược lại rebuild graph trên audioCtx thật) và luôn trả lại window.masterBus.
|
||||
try {
|
||||
const liveCtx = audioCtx;
|
||||
let restored = savedMasterBus;
|
||||
if (!(restored && liveCtx && restored.output && restored.output.context === liveCtx && liveCtx.state !== 'closed')) {
|
||||
masterBus = null;
|
||||
initMasterBus(liveCtx || getAudioContext());
|
||||
restored = masterBus;
|
||||
}
|
||||
masterBus = restored;
|
||||
window.masterBus = restored;
|
||||
} catch (e) {
|
||||
masterBus = savedMasterBus;
|
||||
window.masterBus = savedMasterBus;
|
||||
}
|
||||
try { toggleMasteringOnMaster(!!(masteringSettings && masteringSettings.masterConnected), !!(masteringSettings && masteringSettings.isBypassed)); } catch (e) { }
|
||||
Object.keys(offlineNodes).forEach(k => { try { if (offlineNodes[k].fxStopFn) offlineNodes[k].fxStopFn(); } catch (e) { } });
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -92,6 +92,10 @@ window.API_BASE_URL = (window.API_BASE_URL || window.location.origin).replace(/\
|
||||
midiRender: (payload) => apiRequest('/api/v1/plugins/midi-render', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
listFx: () => apiRequest('/api/v1/plugins/fx', { method: 'GET' }),
|
||||
fxRender: (payload) => apiRequest('/api/v1/plugins/fx-render', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
// Realtime VST FX qua SHM bridge (master chain): start session → WS
|
||||
// /ws/fx-realtime/{id} đẩy/kéo block audio; stop → tắt bridge.
|
||||
fxRealtimeStart: (payload) => apiRequest('/api/v1/plugins/fx-realtime/start', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
fxRealtimeStop: (payload) => apiRequest('/api/v1/plugins/fx-realtime/stop', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
// Mở native GUI của VST FX (mastering chain / FX rack) qua bridge
|
||||
openFxGui: (payload) => apiRequest('/api/v1/plugins/fx-gui', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
soundfontRender: (payload) => apiRequest('/api/v1/plugins/soundfont-render', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// sf-fx-realtime.js — AudioWorkletProcessor nối WebAudio → SHM FX engine.
|
||||
// process() gom 128-sample quantum thành block 256 stereo float32 (khớp
|
||||
// FXRT_BLOCK trong FxRealtimeIPC.h) → postMessage cho main thread gửi WS;
|
||||
// nhận block đã xử lý từ main thread → queue → phát ra output. Queue rỗng →
|
||||
// zero fill (WS chưa kịp round-trip).
|
||||
//
|
||||
// Latency ~2 block; backlog > 32 block (~190ms) → drop block cũ nhất. Cap cao
|
||||
// hấp thụ jitter main thread (rAF renderFrame nặng khi mở MASTERING PANEL) mà
|
||||
// không drop. ponytail: thay drop bằng adaptive stretch khi cần chất lượng cao.
|
||||
class SfFxRealtimeProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this.BLOCK = 256; // FXRT_BLOCK
|
||||
this.inL = new Float32Array(this.BLOCK);
|
||||
this.inR = new Float32Array(this.BLOCK);
|
||||
this.inCount = 0;
|
||||
this.outQueue = []; // [{l: Float32Array, r: Float32Array}]
|
||||
this.outPos = 0;
|
||||
this.port.onmessage = (e) => {
|
||||
const d = e.data;
|
||||
if (!d || d.type !== 'out' || !d.l || !d.r) return;
|
||||
this.outQueue.push({ l: d.l, r: d.r });
|
||||
while (this.outQueue.length > 32) this.outQueue.shift();
|
||||
};
|
||||
}
|
||||
process(inputs, outputs) {
|
||||
const input = inputs[0];
|
||||
const output = outputs[0];
|
||||
const n = output[0] ? output[0].length : 128;
|
||||
const outL = output[0];
|
||||
const outR = output[1];
|
||||
|
||||
// Góp input thành block 256 → gửi main thread (transferable buffer).
|
||||
if (input && input[0]) {
|
||||
const inL = input[0];
|
||||
const inR = input[1] || input[0];
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (this.inCount < this.BLOCK) {
|
||||
this.inL[this.inCount] = inL[i];
|
||||
this.inR[this.inCount] = inR[i];
|
||||
}
|
||||
this.inCount++;
|
||||
if (this.inCount >= this.BLOCK) {
|
||||
const inter = new Float32Array(this.BLOCK * 2);
|
||||
for (let j = 0; j < this.BLOCK; j++) {
|
||||
inter[j * 2] = this.inL[j];
|
||||
inter[j * 2 + 1] = this.inR[j];
|
||||
}
|
||||
this.port.postMessage({ type: 'in', data: inter.buffer }, [inter.buffer]);
|
||||
this.inCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phát output từ queue; rỗng → zero.
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (this.outQueue.length === 0) {
|
||||
outL[i] = 0;
|
||||
if (outR) outR[i] = 0;
|
||||
continue;
|
||||
}
|
||||
const blk = this.outQueue[0];
|
||||
outL[i] = blk.l[this.outPos];
|
||||
if (outR) outR[i] = blk.r[this.outPos];
|
||||
this.outPos++;
|
||||
if (this.outPos >= this.BLOCK) {
|
||||
this.outQueue.shift();
|
||||
this.outPos = 0;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('sf-fx-realtime', SfFxRealtimeProcessor);
|
||||
@@ -51,7 +51,7 @@
|
||||
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
|
||||
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
|
||||
<script src="/static/js/services/chordTheory.js?v=202608211300"></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608211300" defer></script>
|
||||
<script src="/static/js/app.precompiled.js?v=202608212200" defer></script>
|
||||
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
|
||||
<style>
|
||||
:root {
|
||||
|
||||
Reference in New Issue
Block a user