diff --git a/PLAN_SHM_REALTIME.md b/PLAN_SHM_REALTIME.md
new file mode 100644
index 0000000..9413acd
--- /dev/null
+++ b/PLAN_SHM_REALTIME.md
@@ -0,0 +1,109 @@
+# PLAN: VST FX SHM realtime
+
+Mục tiêu: VST3 FX (master FX chain + track chain) xử lý THẬT realtime qua
+fx_vst_bridge — không còn pass-through khi nghe live.
+
+Kiến trúc (cloud-FX loop, latency ~2 block):
+ WebAudio (masterBus.output)
+ → AudioWorklet (sf-fx-realtime.js) — input block
+ → WebSocket (engine /ws/fx-realtime/{id})
+ → Python SHM input ring (SonicForge_FXRT_*)
+ → fx_vst_bridge.exe --realtime-fx (RealtimeFxChain::process, SEH-guarded)
+ → SHM output ring
+ → Python → WebSocket → AudioWorklet output → graph
+
+SHM layout FxRealtimeIPC (C++ struct = Python ctypes mirror, all u32/float 4B):
+ header u32: magic(0x46585254) state sampleRate blockSize running heartbeat
+ inWrite inRead inSlots outWrite outRead outSlots (48 bytes)
+ inL[4][256] inR[4][256] outL[8][256] outR[8][256]
+
+Files:
+ 1. native_bridge/include/FxRealtimeIPC.h — struct + constants (mới)
+ 2. native_bridge/src/RealtimeFxLoop.cpp — loop (mới)
+ 3. native_bridge/src/main_fx.cpp — thêm --realtime-fx
+ 4. native_bridge/CMakeLists.txt — thêm 2 file vào fx_vst_bridge
+ 5. app/core/fx_realtime.py — session SHM + spawn (mới)
+ 6. app/api/v1/plugins.py — /fx-realtime/start, /stop, WS
+ 7. app/static/js/sf-fx-realtime.js — AudioWorklet (mới)
+ 8. app/static/js/app.jsx — fxRealtimeManager wiring
+ 9. engine.spec — hiddenimport websockets
+
+v1 scope: MASTER chain realtime (chỗ VST3 bị skip trong rebuildMasteringGraph).
+Track chain realtime: ponytail — per-track session sau (export offline đã có).
+
+## Trạng thái: ✅ v1 DONE (2026-08-20)
+
+- Bridge build OK (native_bridge/build/Release/fx_vst_bridge.exe) + deploy sidecar
+ (src-tauri/binaries/fx_vst_bridge-x86_64-pc-windows-msvc.exe +
+ src-tauri/target/release/fx_vst_bridge.exe).
+- Engine build OK (websockets bundled) + deploy target/release + resources.
+- Frontend build OK (app.precompiled.js + sf-fx-realtime.js static serve).
+- E2E đã test trên engine deploy (port 8000):
+ - POST /fx-realtime/start (purecomp.vst3, 44100) → session_id + ws_url ✓
+ - WS binary loop: chain rỗng = passthrough bit-exact (peak 0.5/rms 0.35) ✓
+ - WS + purecomp: peak 0.5→0.462, rms 0.35→0.246 (compressor attenuate) ✓
+ - WS disconnect → session tự stop, fx_vst_bridge.exe thoát ✓
+ - POST /fx-realtime/stop → stopped:true ✓
+ - WS token sai → reject (handshake 403) ✓
+- FIX phát hiện khi test: WS drain output chỉ trong vòng receive_bytes → output
+ thừa kẹt SHM ring khi client ngừng gửi → tách _pump_output() task riêng.
+
+## Trạng thái: ✅ TRACK REALTIME DONE (2026-08-20)
+
+- Track chain realtime: mọi luồng âm thanh track qua bridge
+ (fx_vst_bridge --realtime-fx + SHM SonicForge_FXRT_*) trước main out.
+- Module-level `fxRtTracks` map 'trackId:audio|sf' + fxRtTrackChain/Paths/
+ Start/Splice/Stop/Reconcile/StopAll trong app.jsx; gọi từ rebuildTrackFxGraph,
+ startTrackPlayback, stopAllPlayback, FX Rack panel.
+- `fxRtTrackPaths`: audio nếu clips.length>0 || buffer || sections.length>0;
+ sf nếu midiItems.length>0 || type==='MIDI' || instrumentId.
+- E2E ổn định (pw_e2e2.py ×3): splice 3 audio + 3 sf, track audio out
+ peak≈0.47 rms≈0.33, sf câm hợp lệ (MIDI taiko không route sfEntry), master
+ Ozone peak≈0.85-0.99. Master câm khi track mastering_bypass=true — hợp lệ.
+- FIX bug bypass toggle: reconcile đọc state CŨ (React setState async) khi
+ `onChange` gọi `__rebuildTrackFxGraph(track.id)` đồng bộ → bypass không stop
+ session. Fix: `rebuildTrackFxGraph = (trackId, patch)` merge
+ `track = { ...track, ...patch }` TRƯỚC khi rebuild/reconcile; 4 call site
+ (bypass, fxActive, setVstChain, setChain) truyền patch giá trị MỚI.
+ Verify: toggle bypass-on → reconcile want=[] → stop ngay; bypass-off →
+ want=["sf"] → start mới + spliced.
+- Debug log tạm đã gỡ (giữ `[FX RT track] spliced` + console.warn lỗi);
+ rebuild bundle 1370231 bytes, served LEN khớp local.
+
+Ponytail còn lại:
+ - Sub-tab (piano roll) play chưa start fxRt — chỉ main/section play.
+ - Chain đổi giữa lúc play chưa restart session (start/stop theo play boundary).
+ - Browser-side queue drop-oldest (8 block) — thay adaptive stretch nếu cần.
+ - Chưa test UI thật trong WebView (graph insertion AudioWorkletNode) — plumbing
+ đã verify qua WS; mở app + bật master VST chain + play để xác nhận âm thanh.
+
+## Trạng thái: ✅ MASTER REALTIME UI VERIFIED (2026-08-20, engine sống)
+
+- Verify UI-level: login → inject `window.currentMasteringSettings.vstFxChain`
+ (path đúng `C:\Program Files\Common Files\VST3\Sonible\purecomp.vst3`) →
+ insert clip → Play. Monkey-patch `SonicAPI.fxRealtimeStart` ghi payload→sid.
+ Kết quả (pw_master3.py): call #1 = master (purecomp, engine trả session_id +
+ ws_url) → WS `SonicForge_FXRT_d19c01fc33da` nhận audio 738 frame, out
+ peak 0.4622 / rms 0.1442 vs input sine 0.5/0.35 = attenuate ✓.
+ Track sessions (call #2/#3) chạy song song; stop → WS close, `--realtime-fx`
+ bridge thoát, chỉ còn 2 `--fx-gui` của user (không đụng). Engine @8000 OK.
+- WS-level (rt_test2.py) trước đó: master realtime attenuate peak 0.4621 rms
+ 0.2462 với path đúng `Sonible\purecomp.vst3` (path sai `SoniBook` = passthrough
+ thầm lặng).
+- Nguyên nhân "VST FX PLUGIN trong master chain peak -inf" (trả lời user):
+ 1) Engine bị kill lúc test → `SonicAPI.fxRealtimeStart` fail → `fxRtStart`
+ catch + toast cảnh báo, KHÔNG session → masterBus.output nối thẳng
+ analyser → âm nghe được nhưng KHÔNG qua VST → meter plugin -inf.
+ 2) Chain rỗng / vstBypass=true → `fxRtChain()` = [] → không session (âm qua
+ thẳng, không lỗi) → meter -inf.
+ 3) Meter trong GUI embed (`--fx-gui`) là của instance GUI RIÊNG (config JSON
+ chỉ path+name; VstGuiEmbed chỉ /frame /ping /input — KHÔNG có audio
+ endpoint) → GUI embed KHÔNG nhận audio realtime SHM session → meter -inf
+ VÔ ĐIỀU KIỆN kể cả khi realtime đang chạy. Audio thật đi qua instance
+ `--realtime-fx` riêng (không hiển thị meter). → ponytail thật: user không
+ thấy meter plugin thật; nơi thấy tín hiệu = master VU meter của app
+ (post-chain) hoặc WS session.
+- Lưu ý: `masterFxList` (Plugin Picker mastering) load trong useEffect([]) của
+ MasteringModal mount sẵn từ đầu → trước khi SonicAPI sẵn sàng → picker rỗng
+ trong headless; track modal mount trễ hơn nên đầy. Workaround: chờ SonicAPI
+ rồi mới mở modal, hoặc inject chain trực tiếp. (Không phải lỗi engine.)
diff --git a/app/api/v1/files.py b/app/api/v1/files.py
index 0cfa60b..98b9c4b 100644
--- a/app/api/v1/files.py
+++ b/app/api/v1/files.py
@@ -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")
diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py
index 0d66de7..7a12ed2 100644
--- a/app/api/v1/plugins.py
+++ b/app/api/v1/plugins.py
@@ -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í
diff --git a/app/core/fx_realtime.py b/app/core/fx_realtime.py
new file mode 100644
index 0000000..02aec82
--- /dev/null
+++ b/app/core/fx_realtime.py
@@ -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)
diff --git a/app/core/render_engine.py b/app/core/render_engine.py
index 3e9b89b..fcf7153 100644
--- a/app/core/render_engine.py
+++ b/app/core/render_engine.py
@@ -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:
diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx
index 48c3219..1bac872 100644
--- a/app/static/js/app.jsx
+++ b/app/static/js/app.jsx
@@ -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) */}
- VST3 (Ozone...) — realtime browser vẫn WebAudio builtin; VST áp dụng lúc Export/Offline render
+ VST3 — realtime qua bridge (SHM) khi play + áp dụng lúc Export/Offline render
@@ -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) {
diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js
index da6a05e..c0533ef 100644
--- a/app/static/js/app.precompiled.js
+++ b/app/static/js/app.precompiled.js
@@ -267,17 +267,45 @@ function createEqProModule(ctx,params){const input=ctx.createGain();const output
const syncBands=newBands=>{bands.length=0;(newBands||[]).forEach(b=>bands.push({...b}));rebuild();};return{type:'eqpro',input,output,analyser,get bands(){return bands;},get filters(){return filters;},get amount(){return amount;},setBand,setAmount,rebuild,syncBands,destroy(){try{input.disconnect();output.disconnect();analyser.disconnect();}catch(e){}}};}function createTrackFxModule(type,ctx,params){const num=(v,def)=>{const n=Number(v);return isFinite(n)&&!isNaN(n)?n:def;};const maxF=(ctx.sampleRate||44100)*0.45;const clampF=v=>Math.max(20,Math.min(v,maxF));const input=ctx.createGain();const output=ctx.createGain();const p=params||{};let nodes={};if(type==='compressor'){const comp=ctx.createDynamicsCompressor();comp.threshold.value=num(p.threshold,-16);comp.knee.value=8;comp.ratio.value=num(p.ratio,3);comp.attack.value=0.02;comp.release.value=0.25;const makeup=ctx.createGain();makeup.gain.value=Math.pow(10,num(p.makeup,0)/20);input.connect(comp);comp.connect(makeup);makeup.connect(output);nodes={comp,makeup};}else if(type==='limiter'){// Brickwall Limiter bằng WaveShaper tanh soft-clip — KHÔNG DynamicsCompressor:
// Chromium compressor phát NaN với bass transient mạnh (pitch thấp + vel cao
// đồng loạt) → NaN vào master chain → 11 biquad "state is bad" → CÂM + stuck.
-const shaper=ctx.createWaveShaper();shaper.oversample='2x';const ceilingDb=Math.min(0,num(p.ceiling,-1.0));const threshLin=Math.pow(10,ceilingDb/20);const k=1/Math.max(0.02,threshLin);const _curve=new Float32Array(4096);const _tanhK=Math.tanh(k);for(let _i=0;_i<4096;_i++){const _x=_i/4095*2-1;_curve[_i]=Math.tanh(_x*k)/_tanhK;}shaper.curve=_curve;const makeup=ctx.createGain();makeup.gain.value=1.0;input.connect(shaper);shaper.connect(makeup);makeup.connect(output);nodes={shaper,makeup};}else if(type==='exciter'){const hp=ctx.createBiquadFilter();hp.type='highpass';hp.frequency.value=clampF(2000);hp.Q.value=0.7;const shaper=ctx.createWaveShaper();shaper.curve=makeDistortionCurve(3);shaper.oversample='4x';const dry=ctx.createGain();dry.gain.value=1.0;const wet=ctx.createGain();wet.gain.value=num(p.drive,40)/100*0.6;input.connect(dry);dry.connect(output);input.connect(hp);hp.connect(shaper);shaper.connect(wet);wet.connect(output);nodes={hp,shaper,dry,wet};}else if(type==='rebalance'){const split=ctx.createChannelSplitter(2);const merge=ctx.createChannelMerger(2);const gLL=ctx.createGain(),gRL=ctx.createGain(),gLR=ctx.createGain(),gRR=ctx.createGain();const midLin=Math.pow(10,num(p.mid,0)/20);const sideLin=Math.pow(10,num(p.side,0)/20);const a=(midLin+sideLin)/2,b=(midLin-sideLin)/2;gLL.gain.value=a;gRR.gain.value=a;gRL.gain.value=b;gLR.gain.value=b;input.connect(split);split.connect(gLL,0);split.connect(gRL,0);split.connect(gLR,1);split.connect(gRR,1);gLL.connect(merge,0,0);gRL.connect(merge,0,0);gLR.connect(merge,0,1);gRR.connect(merge,0,1);merge.connect(output);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.
+const shaper=ctx.createWaveShaper();shaper.oversample='2x';const ceilingDb=Math.min(0,num(p.ceiling,-1.0));const threshLin=Math.pow(10,ceilingDb/20);const k=1/Math.max(0.02,threshLin);const _curve=new Float32Array(4096);const _tanhK=Math.tanh(k);for(let _i=0;_i<4096;_i++){const _x=_i/4095*2-1;_curve[_i]=Math.tanh(_x*k)/_tanhK;}shaper.curve=_curve;const makeup=ctx.createGain();makeup.gain.value=1.0;input.connect(shaper);shaper.connect(makeup);makeup.connect(output);nodes={shaper,makeup};}else if(type==='exciter'){const hp=ctx.createBiquadFilter();hp.type='highpass';hp.frequency.value=clampF(2000);hp.Q.value=0.7;const shaper=ctx.createWaveShaper();shaper.curve=makeDistortionCurve(3);shaper.oversample='4x';const dry=ctx.createGain();dry.gain.value=1.0;const wet=ctx.createGain();wet.gain.value=num(p.drive,40)/100*0.6;input.connect(dry);dry.connect(output);input.connect(hp);hp.connect(shaper);shaper.connect(wet);wet.connect(output);nodes={hp,shaper,dry,wet};}else if(type==='rebalance'){const split=ctx.createChannelSplitter(2);const merge=ctx.createChannelMerger(2);const gLL=ctx.createGain(),gRL=ctx.createGain(),gLR=ctx.createGain(),gRR=ctx.createGain();const midLin=Math.pow(10,num(p.mid,0)/20);const sideLin=Math.pow(10,num(p.side,0)/20);const a=(midLin+sideLin)/2,b=(midLin-sideLin)/2;gLL.gain.value=a;gRR.gain.value=a;gRL.gain.value=b;gLR.gain.value=b;input.connect(split);split.connect(gLL,0);split.connect(gRL,0);split.connect(gLR,1);split.connect(gRR,1);gLL.connect(merge,0,0);gRL.connect(merge,0,0);gLR.connect(merge,0,1);gRR.connect(merge,0,1);merge.connect(output);nodes={gLL,gRL,gLR,gRR};}else if(type==='eqpro'){return createEqProModule(ctx,params);}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{// 'eq' or default: 4-band EQ (params.g1..g4 = band gains in dB)
const f1=ctx.createBiquadFilter();f1.type='lowshelf';f1.frequency.value=clampF(100);const f2=ctx.createBiquadFilter();f2.type='peaking';f2.frequency.value=clampF(800);f2.Q.value=0.7;const f3=ctx.createBiquadFilter();f3.type='peaking';f3.frequency.value=clampF(3200);f3.Q.value=1.2;const f4=ctx.createBiquadFilter();f4.type='highshelf';f4.frequency.value=clampF(10000);f1.gain.value=num(p.g1,0);f2.gain.value=num(p.g2,0);f3.gain.value=num(p.g3,0);f4.gain.value=num(p.g4,0);input.connect(f1);f1.connect(f2);f2.connect(f3);f3.connect(f4);f4.connect(output);nodes={f1,f2,f3,f4};}return{input,output,nodes,type};}// Offline-export helper: builds a track node IDENTICAL to playback (track FX
// chain + mastering route + legacy chorus/reverb) but inside an OfflineAudioContext,
// 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){if(!track||nodeMap[track.id])return nodeMap[track.id]||null;const gainNode=ctx.createGain();const volDb=track.volumeDb??0;gainNode.gain.setValueAtTime(volDb<=-50?0:Math.pow(10,volDb/20),0);const pannerNode=ctx.createStereoPanner();pannerNode.pan.setValueAtTime((track.pan??0)/100,0);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);let fxStopFn;const fxEntry=ctx.createGain();const fxLegacyIn=ctx.createGain();gainNode.connect(fxEntry);const fxChain=track.fxChain||[];const fxEnabled=track.fxActive!==false;const chainMods=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):[];let fxChainTail=fxEntry;chainMods.forEach(mod=>{fxChainTail.connect(mod.input);fxChainTail=mod.output;});fxChainTail.connect(fxLegacyIn);if(fxEnabled&&track.fxType==='chorus'){const fxInput=ctx.createGain();fxLegacyIn.connect(fxInput);const chorus=createChorusNode(ctx,fxInput,pannerNode);fxStopFn=chorus.stop;}else if(fxEnabled&&track.fxType==='reverb'){const fxInput=ctx.createGain();fxLegacyIn.connect(fxInput);createReverbNode(ctx,fxInput,pannerNode);}else{fxLegacyIn.connect(pannerNode);}const node={gainNode,pannerNode,analyserNode,route,fxStopFn,sfEntry:null,sfOut:null,sfRouteGain:null,sfDryGain:null};// Soundfont (MIDI cache) chain — mirrors the live node so cached MIDI buffers
+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);const pannerNode=ctx.createStereoPanner();pannerNode.pan.setValueAtTime((track.pan??0)/100,0);const analyserNode=ctx.createAnalyser();analyserNode.fftSize=2048;pannerNode.connect(analyserNode);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();gainNode.connect(fxEntry);const fxChain=track.fxChain||[];const fxEnabled=track.fxActive!==false;const chainMods=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):[];let fxChainTail=fxEntry;chainMods.forEach(mod=>{fxChainTail.connect(mod.input);fxChainTail=mod.output;});fxChainTail.connect(fxLegacyIn);if(fxEnabled&&track.fxType==='chorus'){const fxInput=ctx.createGain();fxLegacyIn.connect(fxInput);const chorus=createChorusNode(ctx,fxInput,pannerNode);fxStopFn=chorus.stop;}else if(fxEnabled&&track.fxType==='reverb'){const fxInput=ctx.createGain();fxLegacyIn.connect(fxInput);createReverbNode(ctx,fxInput,pannerNode);}else{fxLegacyIn.connect(pannerNode);}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){const sfEntry=ctx.createGain();sfEntry.gain.setValueAtTime(1,0);const sfOut=ctx.createGain();const sfPan=ctx.createStereoPanner();sfPan.pan.setValueAtTime((track.pan??0)/100,0);sfOut.connect(sfPan);const sfRouteGain=ctx.createGain();const sfDryGain=ctx.createGain();const sfBypass=effMidiBypass(track);sfRouteGain.gain.value=sfBypass?0:1;sfDryGain.gain.value=sfBypass?1:0;sfPan.connect(sfRouteGain);sfPan.connect(sfDryGain);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):[];let sfTail=sfEntry;sfMods.forEach(mod=>{sfTail.connect(mod.input);sfTail=mod.output;});sfTail.connect(sfOut);node.sfEntry=sfEntry;node.sfOut=sfOut;node.sfRouteGain=sfRouteGain;node.sfDryGain=sfDryGain;}nodeMap[track.id]=node;return node;}// Track-EQ preset library (unified_fx_rack_panel.md §III.1) — band gains only.
+if(track.midiItems&&track.midiItems.length>0){const sfEntry=ctx.createGain();sfEntry.gain.setValueAtTime(1,0);const sfOut=ctx.createGain();const sfPan=ctx.createStereoPanner();sfPan.pan.setValueAtTime((track.pan??0)/100,0);sfOut.connect(sfPan);const sfRouteGain=ctx.createGain();const sfDryGain=ctx.createGain();const sfBypass=effMidiBypass(track);sfRouteGain.gain.value=sfBypass?0:1;sfDryGain.gain.value=sfBypass?1:0;sfPan.connect(sfRouteGain);sfPan.connect(sfDryGain);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):[];let sfTail=sfEntry;sfMods.forEach(mod=>{sfTail.connect(mod.input);sfTail=mod.output;});sfTail.connect(sfOut);node.sfEntry=sfEntry;node.sfOut=sfOut;node.sfRouteGain=sfRouteGain;node.sfDryGain=sfDryGain;}nodeMap[track.id]=node;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;is&&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;i0&&!(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;ifxRtStop(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]},vocal_clarity:{name:'Vocal Unmask & Clarity',g:[-2.5,-1.8,3.2,2.0]},bass_punch:{name:'EDM Low-End Punch',g:[4.0,-3.0,1.5,1.0]},warm_tape:{name:'Warm Vintage Analog',g:[2.0,1.0,-2.0,-3.0]},guitar_edge:{name:'Guitar Cut & Presence',g:[-3.0,-2.0,3.5,1.5]}};// Dynamic signal-chain reconstruction (mastering_expand.md §II.3):
// disconnect every module boundary, then wire the ACTIVE modules in series
// between inputAnalyser (chain input) and outputAnalyser (chain output).
@@ -326,13 +354,13 @@ if(enableMonitoring&&destinationTrackGainNode){this.sourceNode.connect(destinati
const totalSamples=this.pcmChunks.reduce((sum,chunk)=>sum+chunk.length,0);if(totalSamples===0)return null;const audioBuffer=this.audioCtx.createBuffer(1,totalSamples,this.audioCtx.sampleRate);const channelData=audioBuffer.getChannelData(0);let offset=0;for(const chunk of this.pcmChunks){channelData.set(chunk,offset);offset+=chunk.length;}return audioBuffer;// Return compiled AudioBuffer for timeline insertion
}}const VolumeKnob=({value,onChange,min=0,max=1})=>{const[isDragging,setIsDragging]=useState(false);const startY=useRef(0);const startValue=useRef(0);const rotation=useMemo(()=>{const percent=(value-min)/(max-min);return-135+percent*270;},[value,min,max]);const handleMouseDown=e=>{setIsDragging(true);startY.current=e.clientY;startValue.current=value;document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};const handleMouseMove=e=>{const deltaY=startY.current-e.clientY;const sensitivity=0.005;const newValue=Math.max(min,Math.min(max,startValue.current+deltaY*sensitivity));onChange(parseFloat(newValue.toFixed(2)));};const handleMouseUp=()=>{setIsDragging(false);document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};return/*#__PURE__*/React.createElement("div",{className:"knob-container cursor-ns-resize flex flex-col items-center",onMouseDown:handleMouseDown,title:`Volume: ${Math.round(value*100)}%`},/*#__PURE__*/React.createElement("svg",{className:"w-7 h-7",viewBox:"0 0 40 40"},/*#__PURE__*/React.createElement("circle",{cx:"20",cy:"20",r:"16",fill:"#141414",stroke:"#444",strokeWidth:"2"}),/*#__PURE__*/React.createElement("g",{transform:`rotate(${rotation} 20 20)`,className:"knob-dial"},/*#__PURE__*/React.createElement("line",{x1:"20",y1:"20",x2:"20",y2:"6",stroke:"#ef4444",strokeWidth:"3",strokeLinecap:"round"}))));};const MixerStrip=({track,index,onUpdateTrack,trackVuRefs})=>{const dbLabel=track.volumeDb==null||track.volumeDb<=-50?'-inf':(track.volumeDb>0?'+':'')+(track.volumeDb||0).toFixed(1)+'dB';const isMuted=track.muted;const isSoloed=track.solo;const isAudioBypassed=!!track.audioBypass;const isMidiBypassed=!!track.midiBypass;const vol=track.volumeDb!=null?track.volumeDb:0;var pct=Math.max(0,Math.min(100,(vol+60)/72*100));var vuColor=pct>=80?'#ef4444':pct>=50?'#eab308':'#22c55e';var trackColor=track.color||'#06b6d4';return React.createElement("div",{className:"flex flex-col items-stretch w-[84px] shrink-0 bg-[#2b2b2b] border border-black/70 overflow-hidden rounded-sm"},React.createElement("div",{className:"flex items-center justify-between px-1 py-0.5 bg-[#222] border-b border-black/60 shrink-0"},React.createElement("span",{className:"text-[9px] font-mono font-bold text-zinc-400"},index+1)),React.createElement("div",{className:"flex items-center justify-center gap-1 py-0.5 shrink-0"},React.createElement("button",{onClick:e=>{e.stopPropagation();const next=!track.muted;if(onUpdateTrack)onUpdateTrack(track.id,{muted:next});if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{muted:next});},title:"Mute",className:"w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition "+(isMuted?'bg-orange-500 text-black border-orange-400':'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')},"M"),React.createElement("button",{onClick:e=>{e.stopPropagation();const next=!track.solo;if(onUpdateTrack)onUpdateTrack(track.id,{solo:next});if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{solo:next});},title:"Solo",className:"w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition "+(isSoloed?'bg-yellow-400 text-black border-yellow-300':'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100')},"S"),React.createElement("button",{onClick:e=>{e.stopPropagation();const next=!(track.audioBypass??track.masteringBypass);if(onUpdateTrack)onUpdateTrack(track.id,{audioBypass:next});// Live audio re-route (applies immediately to playing tracks).
if(window.__setTrackBypass)window.__setTrackBypass(track.id,'audio',next);else if(window.__setTrackMasteringBypass)window.__setTrackMasteringBypass(track.id,next);},title:"A = Mastering FX Chain cho audio items (clips + sections): XÁM = bypass mastering (vẫn qua track FX Rack), SÁNG XANH = qua mastering",className:"w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition "+(isAudioBypassed?'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100':'bg-sky-400 text-black border-sky-300')},"A"),React.createElement("button",{onClick:e=>{e.stopPropagation();const next=!(track.midiBypass??track.masteringBypass);if(onUpdateTrack)onUpdateTrack(track.id,{midiBypass:next});if(window.__setTrackBypass)window.__setTrackBypass(track.id,'midi',next);else if(window.__setTrackMasteringBypass)window.__setTrackMasteringBypass(track.id,next);},title:"♪ = Mastering FX Chain cho MIDI (soundfont): XÁM = bypass mastering (vẫn qua track FX Rack), SÁNG TÍM = qua mastering",className:"w-5 h-5 flex items-center justify-center rounded-sm text-[10px] font-mono font-bold border transition "+(isMidiBypassed?'bg-[#3a3a3a] text-zinc-400 border-black/60 hover:text-zinc-100':'bg-fuchsia-400 text-black border-fuchsia-300')},"\u266A")),React.createElement("div",{className:"flex-1 flex items-stretch justify-center gap-1 px-1 py-1 min-h-0"},React.createElement("div",{className:"w-[30px] rounded-sm relative overflow-hidden bg-[#0d0d0d] border border-black/60 flex flex-col items-center cursor-pointer",onMouseDown:function(e){e.preventDefault();var rect=e.currentTarget.getBoundingClientRect();var tid=track.id;function onMove(ev){var pct=1-Math.max(0,Math.min(1,(ev.clientY-rect.top)/rect.height));var val=Math.round((pct*72-60)*2)/2;if(onUpdateTrack)onUpdateTrack(tid,{volumeDb:val});}function onUp(){document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);onMove(e);}},/* 0dB reference line */React.createElement("div",{className:"absolute w-full h-px bg-amber-400/60 z-10 pointer-events-none",style:{bottom:'83.333%'}}),/* Background gradient */React.createElement("div",{className:"absolute inset-0",style:{background:'linear-gradient(to top, #22c55e, #eab308, #ef4444)'}}),/* Level overlay - dark at TOP, gradient visible at bottom */React.createElement("div",{className:"absolute top-0 w-full transition-all duration-75 bg-[#0d0d0d]",style:{height:100-pct+'%'}})),React.createElement("canvas",{ref:el=>{if(el)trackVuRefs.current[track.id+'_mixer']=el;else delete trackVuRefs.current[track.id+'_mixer'];},width:15,height:120,className:"w-[15px] rounded-sm bg-[#0d0d0d] border border-black/60 block h-full"})),React.createElement("div",{className:"text-center text-[9px] font-mono font-bold py-0.5 "+(vol>0?'text-orange-400':'text-zinc-300')+" bg-[#1c1c1c] border-t border-black/50 shrink-0"},dbLabel),React.createElement("div",{className:"text-[8px] font-mono truncate w-full text-center px-1 py-0.5 bg-[#222] border-t border-black/60 shrink-0",style:{color:trackColor}},track.name));};// ── 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);const[pan,setPan]=React.useState(0.0);const[panText,setPanText]=React.useState('center');const vuCanvasRef=React.useRef(null);const animFrameRef=React.useRef(null);const rmsValRef=React.useRef(null);const peakLRef=React.useRef(null);const peakRRef=React.useRef(null);const panPointerRef=React.useRef(null);const isMasterActive=masteringSettings?masteringSettings.masterConnected:false;const panStartYRef=React.useRef(0);const startPanValRef=React.useRef(0);const ensureAudio=()=>{getAudioContext();};const handleFaderChange=val=>{setMasterVolume(val);ensureAudio();if(masterBus&&masterBus.output){const linear=val<=-50?0:Math.pow(10,val/20);masterBus.output.gain.setTargetAtTime(linear,audioCtx.currentTime,0.01);}};const handlePanPointerDown=e=>{isPanDraggingRef.current=true;panStartYRef.current=e.clientY;startPanValRef.current=pan;e.currentTarget.setPointerCapture(e.pointerId);};const handlePanPointerMove=e=>{if(!isPanDraggingRef.current)return;const deltaY=panStartYRef.current-e.clientY;let newPan=startPanValRef.current+deltaY/80;newPan=Math.min(1.0,Math.max(-1.0,newPan));setPan(newPan);const angle=newPan*120;if(panPointerRef.current)panPointerRef.current.style.transform=`rotate(${angle}deg)`;if(newPan===0)setPanText('center');else if(newPan<0)setPanText('L'+Math.abs(Math.round(newPan*100)));else setPanText('R'+Math.round(newPan*100));};const handlePanPointerUp=e=>{isPanDraggingRef.current=false;try{e.currentTarget.releasePointerCapture(e.pointerId);}catch(err){}};React.useEffect(()=>{const canvas=vuCanvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');function render(){animFrameRef.current=requestAnimationFrame(render);canvas.width=canvas.clientWidth;canvas.height=canvas.clientHeight;const w=canvas.width;const h=canvas.height;ctx.clearRect(0,0,w,h);let levelL=0;let levelR=0;if(masterBus&&masterBus.leftAnalyser&&masterBus.rightAnalyser){const leftData=new Uint8Array(256);const rightData=new Uint8Array(256);masterBus.leftAnalyser.getByteTimeDomainData(leftData);masterBus.rightAnalyser.getByteTimeDomainData(rightData);let peakL=0;let peakR=0;for(let i=0;ipeakL)peakL=v;}for(let i=0;ipeakR)peakR=v;}levelL=peakL;levelR=peakR;}const padding=4;const gap=4;const barW=Math.max(4,(w-padding*2-gap)/2);const grad=ctx.createLinearGradient(0,h,0,0);grad.addColorStop(0,'#10b981');grad.addColorStop(0.7,'#f59e0b');grad.addColorStop(0.95,'#ef4444');ctx.fillStyle=grad;ctx.fillRect(padding,h-levelL*h,barW,levelL*h);ctx.fillRect(padding+barW+gap,h-levelR*h,barW,levelR*h);const maxLevel=Math.max(levelL,levelR);if(rmsValRef.current){rmsValRef.current.innerText=maxLevel>0?(20*Math.log10(maxLevel)-3.2).toFixed(1)+' dB':'-inf';}if(peakLRef.current){peakLRef.current.innerText=levelL>0?(20*Math.log10(levelL)).toFixed(1)+'dB':'-inf';}if(peakRRef.current){peakRRef.current.innerText=levelR>0?(20*Math.log10(levelR)).toFixed(1)+'dB':'-inf';}}render();return()=>{if(animFrameRef.current)cancelAnimationFrame(animFrameRef.current);};},[masterVolume,isMuted,isMono]);return React.createElement("div",{className:"flex flex-col items-stretch w-[300px] shrink-0 strip-bg rounded-lg p-2 text-slate-300 select-none shadow-2xl relative overflow-hidden"},React.createElement("div",{className:"space-y-1.5 mb-2"},React.createElement("button",{onClick:()=>setShowMasteringModal(true),className:"w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-slate-300 tracking-tight"},"FX CHAIN"),React.createElement("div",{className:"flex items-center justify-between bg-slate-950 border border-slate-800 rounded px-1.5 py-0.5 text-[10px] font-mono"},React.createElement("span",{className:"text-slate-400 truncate"},"Output 1 / Output 2"),React.createElement("i",{className:"fa-solid fa-circle-notch text-[9px] text-slate-500"}))),React.createElement("div",{className:"flex flex-col items-center my-0.5"},React.createElement("span",{className:"text-[9px] text-slate-400 font-mono"},panText||'center'),React.createElement("div",{className:"flex items-center gap-1"},React.createElement("span",{className:"text-[8px] font-mono text-slate-500 w-5 text-right"},pan<0?'L'+Math.abs(Math.round(pan*100)):''),React.createElement("div",{id:"panDial",className:"w-6 h-6 rounded-full bg-slate-800 border-2 border-slate-600 relative flex items-center justify-center shadow-inner cursor-pointer",title:"Kéo chuột để chỉnh Pan (Left/Right)",onPointerDown:handlePanPointerDown,onPointerMove:handlePanPointerMove,onPointerUp:handlePanPointerUp,onWheel:function(e){e.preventDefault();var step=e.deltaY>0?-0.05:0.05;var newPan=Math.max(-1,Math.min(1,pan+step));newPan=Math.round(newPan*100)/100;setPan(newPan);if(panPointerRef.current)panPointerRef.current.style.transform='rotate('+newPan*120+'deg)';if(newPan===0)setPanText('center');else if(newPan<0)setPanText('L'+Math.abs(Math.round(newPan*100)));else setPanText('R'+Math.round(newPan*100));},onDoubleClick:function(){setPan(0);setPanText('center');if(panPointerRef.current)panPointerRef.current.style.transform='rotate(0deg)';}},React.createElement("div",{ref:panPointerRef,id:"panPointer",className:"w-0.5 h-2 bg-slate-200 rounded absolute top-0.5 transition-transform",style:{transform:'rotate('+pan*120+'deg)'}})),React.createElement("span",{className:"text-[8px] font-mono text-slate-500 w-5 text-left"},pan>0?'R'+Math.round(pan*100):''),React.createElement("span",{ref:React.createRef?null:null,className:"text-[10px] font-bold font-mono text-slate-200 ml-8",onDoubleClick:function(){handleFaderChange(0);}},masterVolume<=-50?'-inf':(masterVolume>0?'+':'')+masterVolume.toFixed(1)))),React.createElement("div",{className:"flex gap-1 my-1 justify-between items-stretch min-h-0",style:{flex:'1 1 0%'}},React.createElement("div",{className:"flex-1 flex flex-col bg-black/80 p-1.5 rounded border border-slate-800/90 relative shadow-inner"},React.createElement("div",{className:"flex-1 flex items-stretch justify-between min-h-0"},React.createElement("div",{className:"flex flex-col h-full text-[8px] font-mono text-slate-400 select-none pr-1 text-right border-r border-slate-800/60 overflow-hidden"},React.createElement("span",{className:"flex-1 flex items-center justify-end"},"+12"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"+6"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"0"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-6"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-12"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-18"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-24"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-30"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-36"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-42"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-54")),React.createElement("div",{className:"flex-1 relative bg-slate-950 rounded overflow-hidden mx-1.5 border border-slate-900"},React.createElement("canvas",{ref:vuCanvasRef,className:"w-[100px] h-full block"}),React.createElement("div",{className:"absolute bottom-0.5 inset-x-0 flex justify-around text-[7px] font-mono text-slate-500 font-bold pointer-events-none bg-black/60 py-0.5"},React.createElement("span",null,"L"),React.createElement("span",null,"R"))),React.createElement("div",{className:"flex flex-col h-full text-[8px] font-mono text-slate-400 select-none pl-1 text-left border-l border-slate-800/60 overflow-hidden"},React.createElement("span",{className:"flex-1 flex items-center justify-start"},"+12"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"+6"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"0"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-6"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-12"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-18"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-24"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-30"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-36"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-42"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-54"))),React.createElement("div",{className:"flex justify-between text-[9px] font-mono text-slate-400 mt-0.5"},React.createElement("span",{ref:peakLRef},"-inf"),React.createElement("span",{ref:peakRRef},"-inf"))),React.createElement("div",{className:"w-16 flex items-stretch gap-1 bg-slate-900/60 p-1 rounded border border-slate-800"},React.createElement("div",{className:"flex-1 flex flex-col items-center justify-center relative fader-track rounded",onWheel:function(e){e.preventDefault();var delta=e.deltaY>0?-0.5:0.5;handleFaderChange(Math.max(-60,Math.min(12,masterVolume+delta)));}},React.createElement("div",{className:"w-0.5 h-full bg-slate-700 absolute left-1/2 -translate-x-1/2 top-0"}),React.createElement("input",{id:"masterFader",type:"range",min:"-60",max:"12",step:"0.5",value:masterVolume,className:"fader-slider w-full z-10",orient:"vertical",onChange:function(e){handleFaderChange(parseFloat(e.target.value));},onDoubleClick:function(){handleFaderChange(0);}})),React.createElement("div",{className:"relative w-5 text-[7px] font-mono text-slate-500 select-none overflow-hidden"},React.createElement("span",{className:"absolute",style:{top:'0%',right:'2px'}},"+12"),React.createElement("span",{className:"absolute",style:{top:'8.3%',right:'2px'}},"+6"),React.createElement("span",{className:"absolute",style:{top:'16.7%',right:'2px'}},"0"),React.createElement("span",{className:"absolute",style:{top:'25%',right:'2px'}},"-6"),React.createElement("span",{className:"absolute",style:{top:'33.3%',right:'2px'}},"-12"),React.createElement("span",{className:"absolute",style:{top:'50%',right:'2px'}},"-24"),React.createElement("span",{className:"absolute",style:{top:'66.7%',right:'2px'}},"-36"),React.createElement("span",{className:"absolute",style:{top:'91.7%',right:'2px'}},"-54"))),React.createElement("div",{className:"w-8 flex flex-col justify-between text-[10px] font-bold"},React.createElement("button",{id:"monoBtn",onClick:function(){setIsMono(function(p){return!p;});},className:"btn-daw h-[18px] rounded flex flex-col items-center justify-center text-[8px]"+(isMono?" btn-mono-active":""),title:"Mono Switch"},React.createElement("i",{className:"fa-solid fa-circle-half-stroke text-[9px]"}),React.createElement("span",null,"MONO")),React.createElement("button",{id:"muteBtn",onClick:function(){setIsMuted(function(p){return!p;});},className:"btn-daw h-[18px] rounded text-amber-500 font-bold hover:text-amber-400"+(isMuted?" btn-mute-active":""),title:"Mute Master Output"},"M"),React.createElement("button",{id:"soloBtn",className:"btn-daw h-[18px] rounded text-yellow-400 font-bold hover:text-yellow-300",title:"Solo Master"},"S"),React.createElement("button",{className:"btn-daw h-[18px] rounded text-slate-400 hover:text-slate-200",title:"Route Matrix"},React.createElement("i",{className:"fa-solid fa-diagram-project text-[9px]"})),React.createElement("button",{id:"fxBtn",className:"btn-daw h-[18px] rounded font-extrabold text-[10px] transition-all",onClick:function(){setShowMasteringModal(true);},title:"Mở MASTER FX CHAIN để chỉnh sửa"},"FX"),React.createElement("button",{id:"powerBtn",className:"btn-daw h-[18px] rounded text-xs transition-all"+(isMasterActive?" btn-teal-active":""),onClick:function(){if(setMasteringSettings){setMasteringSettings(function(prev){return Object.assign({},prev,{masterConnected:!prev.masterConnected,isBypassed:false});});}},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("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"})))),React.createElement("div",{className:"text-center text-[10px] font-bold font-mono text-slate-200 shrink-0"},masterVolume<=-50?'-inf':(masterVolume>0?'+':'')+masterVolume.toFixed(1)),React.createElement("div",{className:"shrink-0 border-t border-slate-800 flex flex-col items-center"},React.createElement("div",{className:"flex justify-between w-full text-[9px] font-mono py-0.5"},React.createElement("span",{className:"text-emerald-400"},"RMS"),React.createElement("span",{ref:rmsValRef,className:"text-emerald-400 font-bold"},"-inf")),React.createElement("div",{className:"w-full text-center bg-black/80 py-0.5 rounded border border-slate-800 text-[10px] font-extrabold tracking-widest text-slate-100 uppercase"},React.createElement("span",null,"MAIN OUT"))));};// ── Track Strip Console Component (from md/48_TRACK_STRIP_COMPONENT.md) ──
+const MasterStripConsole=({masterVolume,setMasterVolume,showMasteringModal,setShowMasteringModal,masteringSettings,setMasteringSettings,isPlaying})=>{const[isTestPlaying,setIsTestPlaying]=React.useState(false);const[isMuted,setIsMuted]=React.useState(false);const[isMono,setIsMono]=React.useState(false);const[pan,setPan]=React.useState(0.0);const[panText,setPanText]=React.useState('center');const vuCanvasRef=React.useRef(null);const animFrameRef=React.useRef(null);const rmsValRef=React.useRef(null);const peakLRef=React.useRef(null);const peakRRef=React.useRef(null);const panPointerRef=React.useRef(null);const isMasterActive=masteringSettings?masteringSettings.masterConnected:false;const panStartYRef=React.useRef(0);const startPanValRef=React.useRef(0);const ensureAudio=()=>{getAudioContext();};const handleFaderChange=val=>{setMasterVolume(val);ensureAudio();if(masterBus&&masterBus.output){const linear=val<=-50?0:Math.pow(10,val/20);masterBus.output.gain.setTargetAtTime(linear,audioCtx.currentTime,0.01);}};const handlePanPointerDown=e=>{isPanDraggingRef.current=true;panStartYRef.current=e.clientY;startPanValRef.current=pan;e.currentTarget.setPointerCapture(e.pointerId);};const handlePanPointerMove=e=>{if(!isPanDraggingRef.current)return;const deltaY=panStartYRef.current-e.clientY;let newPan=startPanValRef.current+deltaY/80;newPan=Math.min(1.0,Math.max(-1.0,newPan));setPan(newPan);const angle=newPan*120;if(panPointerRef.current)panPointerRef.current.style.transform=`rotate(${angle}deg)`;if(newPan===0)setPanText('center');else if(newPan<0)setPanText('L'+Math.abs(Math.round(newPan*100)));else setPanText('R'+Math.round(newPan*100));};const handlePanPointerUp=e=>{isPanDraggingRef.current=false;try{e.currentTarget.releasePointerCapture(e.pointerId);}catch(err){}};React.useEffect(()=>{const canvas=vuCanvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');function render(){animFrameRef.current=requestAnimationFrame(render);canvas.width=canvas.clientWidth;canvas.height=canvas.clientHeight;const w=canvas.width;const h=canvas.height;ctx.clearRect(0,0,w,h);let levelL=0;let levelR=0;if(masterBus&&masterBus.leftAnalyser&&masterBus.rightAnalyser){const leftData=new Uint8Array(256);const rightData=new Uint8Array(256);masterBus.leftAnalyser.getByteTimeDomainData(leftData);masterBus.rightAnalyser.getByteTimeDomainData(rightData);let peakL=0;let peakR=0;for(let i=0;ipeakL)peakL=v;}for(let i=0;ipeakR)peakR=v;}levelL=peakL;levelR=peakR;}const padding=4;const gap=4;const barW=Math.max(4,(w-padding*2-gap)/2);const grad=ctx.createLinearGradient(0,h,0,0);grad.addColorStop(0,'#10b981');grad.addColorStop(0.7,'#f59e0b');grad.addColorStop(0.95,'#ef4444');ctx.fillStyle=grad;ctx.fillRect(padding,h-levelL*h,barW,levelL*h);ctx.fillRect(padding+barW+gap,h-levelR*h,barW,levelR*h);const maxLevel=Math.max(levelL,levelR);if(rmsValRef.current){rmsValRef.current.innerText=maxLevel>0?(20*Math.log10(maxLevel)-3.2).toFixed(1)+' dB':'-inf';}if(peakLRef.current){peakLRef.current.innerText=levelL>0?(20*Math.log10(levelL)).toFixed(1)+'dB':'-inf';}if(peakRRef.current){peakRRef.current.innerText=levelR>0?(20*Math.log10(levelR)).toFixed(1)+'dB':'-inf';}}render();return()=>{if(animFrameRef.current)cancelAnimationFrame(animFrameRef.current);};},[masterVolume,isMuted,isMono]);return React.createElement("div",{className:"flex flex-col items-stretch w-[300px] shrink-0 strip-bg rounded-lg p-2 text-slate-300 select-none shadow-2xl relative overflow-hidden"},React.createElement("div",{className:"space-y-1.5 mb-2"},React.createElement("button",{onClick:()=>setShowMasteringModal(true),className:"w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-slate-300 tracking-tight"},"FX CHAIN"),React.createElement("div",{className:"flex items-center justify-between bg-slate-950 border border-slate-800 rounded px-1.5 py-0.5 text-[10px] font-mono"},React.createElement("span",{className:"text-slate-400 truncate"},"Output 1 / Output 2"),React.createElement("i",{className:"fa-solid fa-circle-notch text-[9px] text-slate-500"}))),React.createElement("div",{className:"flex flex-col items-center my-0.5"},React.createElement("span",{className:"text-[9px] text-slate-400 font-mono"},panText||'center'),React.createElement("div",{className:"flex items-center gap-1"},React.createElement("span",{className:"text-[8px] font-mono text-slate-500 w-5 text-right"},pan<0?'L'+Math.abs(Math.round(pan*100)):''),React.createElement("div",{id:"panDial",className:"w-6 h-6 rounded-full bg-slate-800 border-2 border-slate-600 relative flex items-center justify-center shadow-inner cursor-pointer",title:"Kéo chuột để chỉnh Pan (Left/Right)",onPointerDown:handlePanPointerDown,onPointerMove:handlePanPointerMove,onPointerUp:handlePanPointerUp,onWheel:function(e){e.preventDefault();var step=e.deltaY>0?-0.05:0.05;var newPan=Math.max(-1,Math.min(1,pan+step));newPan=Math.round(newPan*100)/100;setPan(newPan);if(panPointerRef.current)panPointerRef.current.style.transform='rotate('+newPan*120+'deg)';if(newPan===0)setPanText('center');else if(newPan<0)setPanText('L'+Math.abs(Math.round(newPan*100)));else setPanText('R'+Math.round(newPan*100));},onDoubleClick:function(){setPan(0);setPanText('center');if(panPointerRef.current)panPointerRef.current.style.transform='rotate(0deg)';}},React.createElement("div",{ref:panPointerRef,id:"panPointer",className:"w-0.5 h-2 bg-slate-200 rounded absolute top-0.5 transition-transform",style:{transform:'rotate('+pan*120+'deg)'}})),React.createElement("span",{className:"text-[8px] font-mono text-slate-500 w-5 text-left"},pan>0?'R'+Math.round(pan*100):''),React.createElement("span",{ref:React.createRef?null:null,className:"text-[10px] font-bold font-mono text-slate-200 ml-8",onDoubleClick:function(){handleFaderChange(0);}},masterVolume<=-50?'-inf':(masterVolume>0?'+':'')+masterVolume.toFixed(1)))),React.createElement("div",{className:"flex gap-1 my-1 justify-between items-stretch min-h-0",style:{flex:'1 1 0%'}},React.createElement("div",{className:"flex-1 flex flex-col bg-black/80 p-1.5 rounded border border-slate-800/90 relative shadow-inner"},React.createElement("div",{className:"flex-1 flex items-stretch justify-between min-h-0"},React.createElement("div",{className:"flex flex-col h-full text-[8px] font-mono text-slate-400 select-none pr-1 text-right border-r border-slate-800/60 overflow-hidden"},React.createElement("span",{className:"flex-1 flex items-center justify-end"},"+12"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"+6"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"0"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-6"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-12"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-18"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-24"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-30"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-36"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-42"),React.createElement("span",{className:"flex-1 flex items-center justify-end"},"-54")),React.createElement("div",{className:"flex-1 relative bg-slate-950 rounded overflow-hidden mx-1.5 border border-slate-900"},React.createElement("canvas",{ref:vuCanvasRef,className:"w-[100px] h-full block"}),React.createElement("div",{className:"absolute bottom-0.5 inset-x-0 flex justify-around text-[7px] font-mono text-slate-500 font-bold pointer-events-none bg-black/60 py-0.5"},React.createElement("span",null,"L"),React.createElement("span",null,"R"))),React.createElement("div",{className:"flex flex-col h-full text-[8px] font-mono text-slate-400 select-none pl-1 text-left border-l border-slate-800/60 overflow-hidden"},React.createElement("span",{className:"flex-1 flex items-center justify-start"},"+12"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"+6"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"0"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-6"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-12"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-18"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-24"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-30"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-36"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-42"),React.createElement("span",{className:"flex-1 flex items-center justify-start"},"-54"))),React.createElement("div",{className:"flex justify-between text-[9px] font-mono text-slate-400 mt-0.5"},React.createElement("span",{ref:peakLRef},"-inf"),React.createElement("span",{ref:peakRRef},"-inf"))),React.createElement("div",{className:"w-16 flex items-stretch gap-1 bg-slate-900/60 p-1 rounded border border-slate-800"},React.createElement("div",{className:"flex-1 flex flex-col items-center justify-center relative fader-track rounded",onWheel:function(e){e.preventDefault();var delta=e.deltaY>0?-0.5:0.5;handleFaderChange(Math.max(-60,Math.min(12,masterVolume+delta)));}},React.createElement("div",{className:"w-0.5 h-full bg-slate-700 absolute left-1/2 -translate-x-1/2 top-0"}),React.createElement("input",{id:"masterFader",type:"range",min:"-60",max:"12",step:"0.5",value:masterVolume,className:"fader-slider w-full z-10",orient:"vertical",onChange:function(e){handleFaderChange(parseFloat(e.target.value));},onDoubleClick:function(){handleFaderChange(0);}})),React.createElement("div",{className:"relative w-5 text-[7px] font-mono text-slate-500 select-none overflow-hidden"},React.createElement("span",{className:"absolute",style:{top:'0%',right:'2px'}},"+12"),React.createElement("span",{className:"absolute",style:{top:'8.3%',right:'2px'}},"+6"),React.createElement("span",{className:"absolute",style:{top:'16.7%',right:'2px'}},"0"),React.createElement("span",{className:"absolute",style:{top:'25%',right:'2px'}},"-6"),React.createElement("span",{className:"absolute",style:{top:'33.3%',right:'2px'}},"-12"),React.createElement("span",{className:"absolute",style:{top:'50%',right:'2px'}},"-24"),React.createElement("span",{className:"absolute",style:{top:'66.7%',right:'2px'}},"-36"),React.createElement("span",{className:"absolute",style:{top:'91.7%',right:'2px'}},"-54"))),React.createElement("div",{className:"w-8 flex flex-col justify-between text-[10px] font-bold"},React.createElement("button",{id:"monoBtn",onClick:function(){setIsMono(function(p){return!p;});},className:"btn-daw h-[18px] rounded flex flex-col items-center justify-center text-[8px]"+(isMono?" btn-mono-active":""),title:"Mono Switch"},React.createElement("i",{className:"fa-solid fa-circle-half-stroke text-[9px]"}),React.createElement("span",null,"MONO")),React.createElement("button",{id:"muteBtn",onClick:function(){setIsMuted(function(p){return!p;});},className:"btn-daw h-[18px] rounded text-amber-500 font-bold hover:text-amber-400"+(isMuted?" btn-mute-active":""),title:"Mute Master Output"},"M"),React.createElement("button",{id:"soloBtn",className:"btn-daw h-[18px] rounded text-yellow-400 font-bold hover:text-yellow-300",title:"Solo Master"},"S"),React.createElement("button",{className:"btn-daw h-[18px] rounded text-slate-400 hover:text-slate-200",title:"Route Matrix"},React.createElement("i",{className:"fa-solid fa-diagram-project text-[9px]"})),React.createElement("button",{id:"fxBtn",className:"btn-daw h-[18px] rounded font-extrabold text-[10px] transition-all",onClick:function(){setShowMasteringModal(true);},title:"Mở MASTER FX CHAIN để chỉnh sửa"},"FX"),React.createElement("button",{id:"powerBtn",className:"btn-daw h-[18px] rounded text-xs transition-all"+(isMasterActive?" btn-teal-active":""),onClick:function(){if(setMasteringSettings){setMasteringSettings(function(prev){return Object.assign({},prev,{masterConnected:!prev.masterConnected,isBypassed:false});});}},title:"Bật/Tắt MASTER FX CHAIN Bypass"},[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"})))),React.createElement("div",{className:"text-center text-[10px] font-bold font-mono text-slate-200 shrink-0"},masterVolume<=-50?'-inf':(masterVolume>0?'+':'')+masterVolume.toFixed(1)),React.createElement("div",{className:"shrink-0 border-t border-slate-800 flex flex-col items-center"},React.createElement("div",{className:"flex justify-between w-full text-[9px] font-mono py-0.5"},React.createElement("span",{className:"text-emerald-400"},"RMS"),React.createElement("span",{ref:rmsValRef,className:"text-emerald-400 font-bold"},"-inf")),React.createElement("div",{className:"w-full text-center bg-black/80 py-0.5 rounded border border-slate-800 text-[10px] font-extrabold tracking-widest text-slate-100 uppercase"},React.createElement("span",null,"MAIN OUT"))));};// ── Track Strip Console Component (from md/48_TRACK_STRIP_COMPONENT.md) ──
const TrackStripConsole=({track,index,onUpdateTrack,trackVuRefs,style})=>{var vol=track.volumeDb!=null?track.volumeDb:0;var trackColor=track.color||'#06b6d4';var isMuted=track.muted;var isSoloed=track.solo;var isBypassed=!!(track.audioBypass??track.masteringBypass);var isArmed=track.isArmed;var trackName=track.name||'Track '+(index+1);var isMicActive=track.inputSource?.deviceType==='MICROPHONE';const[pan,setPan]=React.useState(0.0);const[panLabel,setPanLabel]=React.useState('center');const[isPhaseInverted,setIsPhaseInverted]=React.useState(false);const panPointerRef=React.useRef(null);const setVuCanvas=React.useCallback(function(el){if(el)trackVuRefs.current[track.id+'_mixer']=el;else delete trackVuRefs.current[track.id+'_mixer'];},[track.id,trackVuRefs]);// Track FX chain editor (mastering_expand.md §II.4): reuse the same module
// types as the mastering suite inside this track's FX chain.
var FX_MODULE_TYPES=['compressor','limiter','exciter','rebalance','eq'];const updateFxChain=function(nextChain){if(onUpdateTrack)onUpdateTrack(track.id,{fxChain:nextChain});};const handlePanPointerDown=e=>{e.currentTarget._panStartY=e.clientY;e.currentTarget._startPan=pan;e.currentTarget.setPointerCapture(e.pointerId);function onMove(ev){if(!e.currentTarget)return;var deltaY=e.currentTarget._panStartY-ev.clientY;var newPan=Math.min(1.0,Math.max(-1.0,e.currentTarget._startPan+deltaY/80));setPan(newPan);var angle=newPan*120;if(panPointerRef.current)panPointerRef.current.style.transform='rotate('+angle+'deg)';if(newPan===0)setPanLabel('center');else if(newPan<0)setPanLabel('L'+Math.abs(Math.round(newPan*100)));else setPanLabel('R'+Math.round(newPan*100));}function onUp(){document.removeEventListener('pointermove',onMove);document.removeEventListener('pointerup',onUp);}document.addEventListener('pointermove',onMove);document.addEventListener('pointerup',onUp);};return React.createElement("div",{style:style||undefined,className:"flex flex-col items-stretch w-[145px] shrink-0 strip-bg-track rounded-md text-slate-300 select-none shadow-2xl border border-slate-900 overflow-y-auto"},/* 1. Top Track Color Accent Bar */React.createElement("div",{className:"h-1.5 w-full shrink-0 transition-colors",style:{backgroundColor:trackColor}}),/* 2. Pan Rotary Dial Area */React.createElement("div",{className:"h-[46px] shrink-0 py-1 px-2 flex flex-col items-center justify-center border-b border-slate-700/40",style:{backgroundColor:trackColor+'15'}},React.createElement("div",{className:"w-6 h-6 rounded-full bg-slate-800 border-2 border-slate-400 relative flex items-center justify-center cursor-pointer shadow-md",title:"Kéo chuột lên/xuống để chỉnh Pan",onPointerDown:handlePanPointerDown,onDoubleClick:function(){setPan(0);setPanLabel('center');if(panPointerRef.current)panPointerRef.current.style.transform='rotate(0deg)';},onWheel:function(e){e.preventDefault();var step=e.deltaY>0?-0.05:0.05;var newPan=Math.max(-1,Math.min(1,pan+step));newPan=Math.round(newPan*100)/100;setPan(newPan);if(panPointerRef.current)panPointerRef.current.style.transform='rotate('+newPan*120+'deg)';if(newPan===0)setPanLabel('center');else if(newPan<0)setPanLabel('L'+Math.abs(Math.round(newPan*100)));else setPanLabel('R'+Math.round(newPan*100));}},React.createElement("div",{ref:panPointerRef,className:"w-0.5 h-2 rounded absolute top-0.5 transition-transform",style:{backgroundColor:trackColor,transform:'rotate(0deg)'}})),React.createElement("span",{className:"text-[8px] font-mono mt-0.5 font-semibold",style:{color:trackColor}},panLabel)),/* 3. Center Area: Peak dB + Fader + VU + Button Stack */React.createElement("div",{className:"flex-1 p-1 flex gap-1 justify-between items-stretch min-h-[170px]"},/* Left Fader & VU Column */React.createElement("div",{className:"flex-1 flex flex-col justify-between items-center bg-black/60 p-1 rounded border border-slate-800/80"},React.createElement("div",{className:"w-full flex justify-center text-[8px] font-mono text-slate-400 h-4 items-center"},React.createElement("span",null,vol<=-50?'-inf':(vol>0?'+':'')+vol.toFixed(1)+'dB')),React.createElement("div",{className:"flex items-stretch justify-around w-full flex-1 relative py-1"},/* Fader Rail */React.createElement("div",{className:"relative fader-track-bg w-3 flex-1 rounded flex items-center justify-center overflow-hidden"},React.createElement("div",{className:"w-0.5 h-full bg-slate-700 absolute"}),React.createElement("input",{type:"range",min:"-60",max:"12",step:"0.5",value:vol,className:"fader-slider w-full z-10",onChange:function(e){var val=parseFloat(e.target.value);if(onUpdateTrack)onUpdateTrack(track.id,{volumeDb:val});},onWheel:function(e){e.preventDefault();var step=e.deltaY>0?-0.5:0.5;var newVol=Math.max(-60,Math.min(12,vol+step));if(onUpdateTrack)onUpdateTrack(track.id,{volumeDb:newVol});},onDoubleClick:function(){if(onUpdateTrack)onUpdateTrack(track.id,{volumeDb:0});}})),/* VU Meter */React.createElement("div",{className:"w-2.5 flex-1 bg-slate-950 rounded border border-slate-900 overflow-hidden relative",title:"Peak VU Meter"},React.createElement("canvas",{ref:setVuCanvas,className:"w-full h-full block"})))),/* Right Button Stack */React.createElement("div",{className:"w-7 flex flex-col justify-between text-[8px] font-bold shrink-0"},React.createElement("button",{onClick:function(e){e.stopPropagation();var next=!track.muted;if(onUpdateTrack)onUpdateTrack(track.id,{muted:next});if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{muted:next});},className:"btn-daw h-[24px] rounded text-amber-500 font-extrabold flex items-center justify-center"+(isMuted?" btn-mute-active":""),title:"Mute Track"},"M"),React.createElement("button",{onClick:function(e){e.stopPropagation();var next=!track.solo;if(onUpdateTrack)onUpdateTrack(track.id,{solo:next});if(window.__applyTrackMuteSolo)window.__applyTrackMuteSolo(track.id,{solo:next});},className:"btn-daw h-[24px] rounded text-yellow-400 font-extrabold flex items-center justify-center"+(isSoloed?" btn-solo-active":""),title:"Solo Track"},"S"),React.createElement("button",{onClick:function(e){e.stopPropagation();var next=!(track.audioBypass??track.masteringBypass);if(onUpdateTrack)onUpdateTrack(track.id,{audioBypass:next});// Live re-route: bypassed channel skips FX + mastering chain at Main out.
if(window.__setTrackBypass)window.__setTrackBypass(track.id,'audio',next);else if(window.__setTrackMasteringBypass)window.__setTrackMasteringBypass(track.id,next);},className:"btn-daw h-[24px] rounded flex items-center justify-center font-extrabold text-[9px] "+(track.audioBypass?" text-slate-500":" text-sky-300 bg-sky-500/30 border-sky-400/70"),title:"A = Mastering FX Chain cho audio items (clips + sections): XÁM = bypass mastering (vẫn qua track FX Rack), SÁNG XANH = qua mastering"},"A"),React.createElement("button",{onClick:function(e){e.stopPropagation();var next=!(track.midiBypass??track.masteringBypass);if(onUpdateTrack)onUpdateTrack(track.id,{midiBypass:next});if(window.__setTrackBypass)window.__setTrackBypass(track.id,'midi',next);else if(window.__setTrackMasteringBypass)window.__setTrackMasteringBypass(track.id,next);},className:"btn-daw h-[24px] rounded flex items-center justify-center font-extrabold text-[10px] "+(track.midiBypass?" text-slate-500":" text-fuchsia-300 bg-fuchsia-500/30 border-fuchsia-400/70"),title:"♪ = Mastering FX Chain cho MIDI (soundfont): XÁM = bypass mastering (vẫn qua track FX Rack), SÁNG TÍM = qua mastering"},"\u266A"),React.createElement("button",{onClick:function(){if(window.__openFxRack)window.__openFxRack(track.id,track.name);},className:"btn-daw h-[24px] rounded text-slate-300 font-semibold flex items-center justify-center text-[7px]"+((track.fxChain||[]).length>0?" text-cyan-400":""),title:"Track FX Chain (mastering_expand.md §II.4)"},"FX"),React.createElement("button",{onClick:function(){var next=track.fxActive===false;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);},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"},React.createElement("i",{className:"fa-solid fa-power-off"})),React.createElement("button",{className:"btn-daw h-[24px] rounded text-slate-400 flex items-center justify-center",title:"Automation Envelopes"},React.createElement("i",{className:"fa-solid fa-chart-line text-[8px]"})),React.createElement("button",{onClick:function(){setIsPhaseInverted(function(p){return!p;});},className:"btn-daw h-[24px] rounded flex items-center justify-center text-[9px]"+(isPhaseInverted?" bg-amber-600 text-white":" text-slate-400"),title:"Phase Invert"},"\u00D8"))),/* 4. Record Arm Button Row */React.createElement("div",{className:"h-[28px] shrink-0 flex items-center justify-between mx-1.5 px-1.5 bg-black/40 rounded border border-slate-800/60"},React.createElement("button",{onClick:function(e){e.stopPropagation();if(!onUpdateTrack)return;if(track.inputSource?.deviceType==='MICROPHONE'){onUpdateTrack(track.id,{inputSource:{deviceType:'NONE',deviceId:''}});}else{onUpdateTrack(track.id,{inputSource:{deviceType:'MICROPHONE',deviceId:'default'}});}},className:"w-4 h-4 rounded flex items-center justify-center text-[7px] font-bold transition-all"+(isMicActive?" bg-sky-600 text-white border border-sky-400 shadow-sm":" bg-slate-800 text-slate-500 border border-slate-700"),title:isMicActive?"Mic Input ON":"Mic Input OFF"},"MIC"),React.createElement("button",{onClick:function(e){e.stopPropagation();if(onUpdateTrack)onUpdateTrack(track.id,{isArmed:!track.isArmed});},className:"w-5 h-5 rounded-full flex items-center justify-center transition-all shadow-inner"+(isArmed?" btn-arm-active border-red-400":" bg-red-950 border-2 border-red-800 text-red-500"),title:"Arm for Recording"},React.createElement("i",{className:"fa-solid fa-circle text-[8px]"}))),/* 5. Track Name Identifier */React.createElement("div",{className:"h-[26px] shrink-0 mx-1.5 flex items-center justify-center bg-slate-950/80 rounded border border-slate-800/80"},React.createElement("span",{className:"text-[11px] font-bold tracking-wider font-sans uppercase",style:{color:trackColor}},trackName)),/* 6. Footer Bar */React.createElement("div",{className:"h-[22px] shrink-0 w-full text-slate-950 flex items-center justify-center font-extrabold text-xs font-mono tracking-widest transition-colors",style:{backgroundColor:trackColor}},index+1));};const WaveformLane=({track,zoom,timelineWidth,viewportWidth,onSelectRange,onPlayheadSet,isSelected,onSelectTrack,markers,selectionMode,localSelectionTrackId,localSelectionStart,currentTime,getLocalAnchor,onClearLocalSelection,onDeselectItem,onAddToSelection,onSetPendingDrag,onSetPendingDragMove,onSetSelectionMode,onSetSelectionStart,onSetSelectionEnd,onSetCurrentTime,onSetLocalSelectionTrackId,onSetLocalSelectionStart,onSetLocalSelectionEnd,localSelLeft,localSelRight,onTrackLaneMouseDown,onContextMenu,onClipDragStart,onClipStretchStart,onSectionItemDragStart,onSectionItemResizeStart,onEditSectionInTab,onEditMidiInTab,onSelectionEdgeDragStart,setSelectedClipId,selectedClipId,activeTool,onSplitTrackAtTime,onEditClipInSubTab,selectedItemIds,onClearSelection,onSweepSelectStart,snapValue,bpm,scrollLeft,recordingState,recTempMidiNotes,recTempAudioBuffer,recStartTimelineTime,canvasRedrawCount})=>{const canvasRef=useRef(null);const drawWidth=Math.min(timelineWidth,viewportWidth);const leadInMargin=0;useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;let scrollLeftVal=scrollLeft||0;let el=canvas.parentElement;while(el){if(el.scrollLeft!==undefined&&(el.scrollWidth>el.clientWidth||el.scrollLeft>0)){scrollLeftVal=el.scrollLeft;break;}el=el.parentElement;}const vWidth=viewportWidth||1200;const height=canvas.parentElement?canvas.parentElement.clientHeight:96;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle=isSelected?'#2a2a2a':track.id%2===0?'#181818':'#1d1d1d';ctx.fillRect(0,0,drawWidth,height);// Grid lines based on Snap value
+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"},React.createElement("i",{className:"fa-solid fa-power-off"})),React.createElement("button",{className:"btn-daw h-[24px] rounded text-slate-400 flex items-center justify-center",title:"Automation Envelopes"},React.createElement("i",{className:"fa-solid fa-chart-line text-[8px]"})),React.createElement("button",{onClick:function(){setIsPhaseInverted(function(p){return!p;});},className:"btn-daw h-[24px] rounded flex items-center justify-center text-[9px]"+(isPhaseInverted?" bg-amber-600 text-white":" text-slate-400"),title:"Phase Invert"},"\u00D8"))),/* 4. Record Arm Button Row */React.createElement("div",{className:"h-[28px] shrink-0 flex items-center justify-between mx-1.5 px-1.5 bg-black/40 rounded border border-slate-800/60"},React.createElement("button",{onClick:function(e){e.stopPropagation();if(!onUpdateTrack)return;if(track.inputSource?.deviceType==='MICROPHONE'){onUpdateTrack(track.id,{inputSource:{deviceType:'NONE',deviceId:''}});}else{onUpdateTrack(track.id,{inputSource:{deviceType:'MICROPHONE',deviceId:'default'}});}},className:"w-4 h-4 rounded flex items-center justify-center text-[7px] font-bold transition-all"+(isMicActive?" bg-sky-600 text-white border border-sky-400 shadow-sm":" bg-slate-800 text-slate-500 border border-slate-700"),title:isMicActive?"Mic Input ON":"Mic Input OFF"},"MIC"),React.createElement("button",{onClick:function(e){e.stopPropagation();if(onUpdateTrack)onUpdateTrack(track.id,{isArmed:!track.isArmed});},className:"w-5 h-5 rounded-full flex items-center justify-center transition-all shadow-inner"+(isArmed?" btn-arm-active border-red-400":" bg-red-950 border-2 border-red-800 text-red-500"),title:"Arm for Recording"},React.createElement("i",{className:"fa-solid fa-circle text-[8px]"}))),/* 5. Track Name Identifier */React.createElement("div",{className:"h-[26px] shrink-0 mx-1.5 flex items-center justify-center bg-slate-950/80 rounded border border-slate-800/80"},React.createElement("span",{className:"text-[11px] font-bold tracking-wider font-sans uppercase",style:{color:trackColor}},trackName)),/* 6. Footer Bar */React.createElement("div",{className:"h-[22px] shrink-0 w-full text-slate-950 flex items-center justify-center font-extrabold text-xs font-mono tracking-widest transition-colors",style:{backgroundColor:trackColor}},index+1));};const WaveformLane=({track,zoom,timelineWidth,viewportWidth,onSelectRange,onPlayheadSet,isSelected,onSelectTrack,markers,selectionMode,localSelectionTrackId,localSelectionStart,currentTime,getLocalAnchor,onClearLocalSelection,onDeselectItem,onAddToSelection,onSetPendingDrag,onSetPendingDragMove,onSetSelectionMode,onSetSelectionStart,onSetSelectionEnd,onSetCurrentTime,onSetLocalSelectionTrackId,onSetLocalSelectionStart,onSetLocalSelectionEnd,localSelLeft,localSelRight,onTrackLaneMouseDown,onContextMenu,onClipDragStart,onClipStretchStart,onSectionItemDragStart,onSectionItemResizeStart,onEditSectionInTab,onEditMidiInTab,onSelectionEdgeDragStart,setSelectedClipId,selectedClipId,activeTool,onSplitTrackAtTime,onEditClipInSubTab,selectedItemIds,onClearSelection,onSweepSelectStart,snapValue,bpm,scrollLeft,recordingState,recTempMidiNotes,recTempAudioBuffer,recStartTimelineTime,canvasRedrawCount})=>{const canvasRef=useRef(null);const drawWidth=Math.min(timelineWidth,viewportWidth);const leadInMargin=0;useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;let scrollLeftVal=scrollLeft||0;let el=canvas.parentElement;while(el){if(el.scrollLeft!==undefined&&(el.scrollWidth>el.clientWidth||el.scrollLeft>0)){scrollLeftVal=el.scrollLeft;break;}el=el.parentElement;}const vWidth=viewportWidth||1200;const height=canvas.parentElement?canvas.parentElement.clientHeight:96;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle=isSelected?'#2a2a2a':track.id%2===0?'#181818':'#1d1d1d';ctx.fillRect(0,0,drawWidth,height);// Grid lines based on Snap value
ctx.strokeStyle='rgba(255, 255, 255, 0.03)';ctx.lineWidth=1;const beatDuration=60.0/(parseFloat(bpm)||120);const barDuration=beatDuration*4;const leadIn=0;const CLIP_BUFFER=Math.max(400,barDuration*zoom+200);const PADDING_LEFT=0;const tStart=(scrollLeftVal-leadIn*zoom)/zoom-PADDING_LEFT;const tEnd=(scrollLeftVal+drawWidth-leadIn*zoom)/zoom+CLIP_BUFFER/zoom;let snapDivisor=1;if(snapValue&&snapValue!=='free'){if(snapValue==='4')snapDivisor=4;else if(snapValue==='1')snapDivisor=1;else if(snapValue==='1/2')snapDivisor=0.5;else if(snapValue==='1/4')snapDivisor=0.25;else if(snapValue==='1/8')snapDivisor=0.125;else if(snapValue==='1/16')snapDivisor=0.0625;else if(snapValue==='1/32')snapDivisor=0.03125;}const snapInterval=beatDuration*snapDivisor;const firstSnap=Math.floor(tStart/snapInterval)*snapInterval;for(let t=firstSnap;t<=tEnd;t+=snapInterval){const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;const barNum=Math.round(t/barDuration);const onBar=Math.abs(t-barNum*barDuration)<0.001;ctx.strokeStyle=onBar?'rgba(255, 255, 255, 0.12)':'rgba(255, 255, 255, 0.04)';ctx.lineWidth=onBar?1.2:0.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();if(onBar&&zoom>=2){ctx.fillStyle='rgba(255, 255, 255, 0.15)';ctx.font='bold 7px Inter, sans-serif';ctx.textAlign='left';ctx.fillText(`${barNum}`,localX+2,10);}}// Draw waveform lane
const clips=[...(track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[])];if(recordingState==='RECORDING'&&track.isArmed&&track.inputSource?.deviceType==='MICROPHONE'&&recTempAudioBuffer){clips.push({id:'rec_temp_'+track.id,buffer:recTempAudioBuffer,startTime:recStartTimelineTime,name:'[GHI ÂM...]',speed:1.0,isTemp:true});}if(clips.length>0){clips.forEach(clip=>{const hasBuffer=!!clip.buffer;const clipSpeed=clip.speed||1.0;const originalDuration=hasBuffer?clip.buffer.duration/clipSpeed:4.0;const duration=originalDuration;const clipStartTime=clip.startTime||0;const clipEndTime=clipStartTime+duration;// Culling: Skip rendering if clip is outside visible viewport window
if(clipEndTimetEnd)return;const xStartGlobal=clipStartTime*zoom;const wClip=duration*zoom;const xStartLocal=xStartGlobal-scrollLeftVal;const xEndLocal=xStartLocal+wClip;// 1. Draw Clip Layer Background & Border (always draw even without buffer)
@@ -717,7 +745,7 @@ if(!s.sectionId)return;items.push({id:s.id,name:s.name||"Section Item",type:"SEC
const visited=visitedSections||new Set();const curDepth=depth||0;if(curDepth>12)return[];return(schemaTracks||[]).map(t=>{const clips=[];const sections=[];const midiItems=[];(t.items||[]).forEach(item=>{if(item.type==="AUDIO_ITEM"){const src=item.source_data||{};const clipFileId=src.server_file_id||(src.audio_file_url?src.audio_file_url.split('/').pop():null)||null;clips.push({id:item.id,name:item.name,startTime:item.start_bar*secondsPerBar,speed:1.0,duration:item.duration_bars*secondsPerBar,serverFileId:clipFileId});}else if(item.type==="MIDI_ITEM"){const src=item.source_data||{};midiItems.push({id:item.id,name:item.name,parent_track_id:t.id,startTime:item.start_bar*secondsPerBar,duration:item.duration_bars*secondsPerBar,length_bars:item.duration_bars||4,notes:(src.notes||[]).map(n=>({id:n.id,pitch:n.pitch||60,start_beat:n.start_beat||0.0,duration_beats:n.duration_beats||1.0,velocity:n.velocity||0.8,pan:n.pan||0.0}))});}else if(item.type==="SECTION_ITEM"){const src=item.source_data||{};const secId=src.referenced_section_id;const secContainer=sectionStore?sectionStore[secId]:null;const isCycle=secContainer?visited.has(secId):false;sections.push({id:item.id,name:item.name,start:item.start_bar*secondsPerBar,duration:item.duration_bars*secondsPerBar,length_bars:item.duration_bars||4,sectionId:secId,tracks:secContainer&&!isCycle?deserializeTracksList(secContainer.tracks,secondsPerBar,sectionStore,new Set(visited).add(secId),curDepth+1):null});}});return{id:t.id,name:t.name,type:t.type==='MIDI'?'MIDI':t.type==='SECTION'?'SECTION':'audio',volumeDb:t.volume_db||0.0,pan:t.pan||0.0,muted:t.mute||false,solo:t.solo||false,masteringBypass:t.audio_bypass!==undefined?t.audio_bypass:t.mastering_bypass||false,audioBypass:t.audio_bypass!==undefined?t.audio_bypass:t.mastering_bypass||false,midiBypass:t.midi_bypass!==undefined?t.midi_bypass:t.mastering_bypass||false,fxActive:t.fx_active!==undefined?t.fx_active:true,color:t.color||(t.id==='1'?'#0f766e':'#1d4ed8'),startTime:t.start_time||0,height:t.height||140,markers:t.markers||[],serverFileId:t.server_file_id||t.items&&t.items.find(function(i){return i.type==='AUDIO_ITEM';})?.source_data?.server_file_id||t.items&&t.items.find(function(i){return i.type==='AUDIO_ITEM';})?.source_data?.audio_file_url?.split('/').pop()||null,channelInfo:t.channel_info||null,isArmed:t.is_armed||false,monitoringEnabled:t.monitoring_enabled!==false,inputSource:t.input_source?{deviceType:t.input_source.device_type||'NONE',deviceId:t.input_source.device_id||''}:{deviceType:'NONE',deviceId:''},midiChannel:t.midi_channel!=null?t.midi_channel:undefined,is_percussion:t.is_percussion||false,clips:clips,sections:sections,midiItems:midiItems,instrumentId:t.instrumentId!=null?t.instrumentId:t.instrument_id||null,instrumentProgram:t.instrumentProgram!==undefined&&t.instrumentProgram!==null?t.instrumentProgram:t.instrument_program!==null?t.instrument_program:undefined,instrumentName:t.instrument_name||null,instrument_source:t.instrument_source||null,soundfont_id:t.soundfont_id||null,soundfont_bank:t.soundfont_bank!==null?t.soundfont_bank:undefined,soundfont_program:t.soundfont_program!==null?t.soundfont_program:undefined,synth_engine:t.synth_engine||undefined,fxChain:(t.fx_chain||[]).map(m=>typeof m==='string'?{type:m,active:true}:{type:m.type||m,active:m.active!==false}),vstFxChain:(t.vst_fx_chain||[]).map(s=>({type:s.type||'vst3',path:s.path||'',name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass})),vstFxBypass:t.vst_fx_bypass!==undefined?!!t.vst_fx_bypass:false,vstGuiOpenPath:t.vst_gui_open_path||null,vstGuiOpenName:t.vst_gui_open_name||null};});};const serializeProjectToSchema=(projectId,name,bpmVal,tracksList,subTabsList,sessionTabsList,masteringSettings,masterVolume)=>{const secondsPerBar=60.0/parseFloat(bpmVal||120)*4;const mainTracks=serializeTracksList(tracksList,secondsPerBar);const sectionStore={};// Helper: compute length_bars from tracks content
const computeLengthBars=(tracksArr,spb)=>{let maxSec=0;(tracksArr||[]).forEach(tr=>{(tr.clips||[]).forEach(c=>{const end=(c.startTime||0)+(c.buffer?c.buffer.duration/(c.speed||1.0):4);if(end>maxSec)maxSec=end;});(tr.midiItems||[]).forEach(m=>{const end=(m.startTime||0)+(m.duration||4);if(end>maxSec)maxSec=end;});});return Math.ceil((maxSec||4)/spb);};// 1. Populate from sessionTabsList (open tabs)
(sessionTabsList||[]).forEach(st=>{const serializedTracks=serializeTracksList(st.tracks,secondsPerBar);sectionStore[st.sectionId]={id:st.sectionId,name:st.name,is_root:false,length_bars:computeLengthBars(st.tracks,secondsPerBar),auto_compute_length:true,tracks:serializedTracks,color:st.color||null};});// 2. Also populate from tracksList (closed tabs saved inside Section items)
-const scanForSections=tracks=>{(tracks||[]).forEach(t=>{if(t.sections){t.sections.forEach(s=>{const secId=s.sectionId||s.id;if(s.tracks&&!sectionStore[secId]){sectionStore[secId]={id:secId,name:s.name,is_root:false,length_bars:computeLengthBars(s.tracks,secondsPerBar),auto_compute_length:true,tracks:serializeTracksList(s.tracks,secondsPerBar),color:s.color||null};}if(s.tracks){scanForSections(s.tracks);}});}});};scanForSections(tracksList);const subTabs=(subTabsList||[]).map(st=>{return{id:st.id,label:st.label,type:st.type,track_id:st.trackId,target_id:st.target_id,parent_tab_id:st.parent_tab_id,notes:st.notes||[],duration:st.duration||4,instrument_program:st.instrumentProgram,instrument_name:st.instrumentName,instrument_id:st.instrumentId||st.synth_engine&&st.synth_engine.plugin_id||null,synth_engine:st.synth_engine||null,current_time:st.currentTime||0,color:st.color||null};});return{project_id:projectId||'proj_'+Date.now(),metadata:{title:name||"Dự án mới",bpm:parseFloat(bpmVal||120),time_signature_numerator:4,time_signature_denominator:4,sample_rate:44100,zoom:window.__sfTimelineZoom||window.__currentZoom||1.0},main_session:{id:"main",name:"MAIN SESSION",is_root:true,length_bars:(()=>{let maxBar=16.0;(mainTracks||[]).forEach(t=>{(t.items||[]).forEach(item=>{const end=(item.start_bar||0)+(item.duration_bars||4);if(end>maxBar)maxBar=end;});});return maxBar;})(),auto_compute_length:true,tracks:mainTracks,...(masteringSettings&&Array.isArray(masteringSettings.vstFxChain)&&masteringSettings.vstFxChain.length>0||typeof masterVolume==='number'&&isFinite(masterVolume)&&masterVolume!==0?{master:{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}}:{})},sub_tabs:subTabs,section_store:sectionStore,mastering_settings:masteringSettings?{...masteringSettings,gui_open_path:_persistMasterGuiOpen&&_persistMasterGuiOpen.path?_persistMasterGuiOpen.path:masteringSettings&&masteringSettings.gui_open_path||null,gui_open_name:_persistMasterGuiOpen&&_persistMasterGuiOpen.name?_persistMasterGuiOpen.name:masteringSettings&&masteringSettings.gui_open_name||null}:null};};const deserializeProjectFromSchema=schemaObj=>{// GUI knob captures are per-project - don't leak presets between projects.
+const scanForSections=tracks=>{(tracks||[]).forEach(t=>{if(t.sections){t.sections.forEach(s=>{const secId=s.sectionId||s.id;if(s.tracks&&!sectionStore[secId]){sectionStore[secId]={id:secId,name:s.name,is_root:false,length_bars:computeLengthBars(s.tracks,secondsPerBar),auto_compute_length:true,tracks:serializeTracksList(s.tracks,secondsPerBar),color:s.color||null};}if(s.tracks){scanForSections(s.tracks);}});}});};scanForSections(tracksList);const subTabs=(subTabsList||[]).map(st=>{return{id:st.id,label:st.label,type:st.type,track_id:st.trackId,target_id:st.target_id,parent_tab_id:st.parent_tab_id,notes:st.notes||[],duration:st.duration||4,instrument_program:st.instrumentProgram,instrument_name:st.instrumentName,instrument_id:st.instrumentId||st.synth_engine&&st.synth_engine.plugin_id||null,synth_engine:st.synth_engine||null,current_time:st.currentTime||0,color:st.color||null};});return{project_id:projectId||'proj_'+Date.now(),metadata:{title:name||"Dự án mới",bpm:parseFloat(bpmVal||120),time_signature_numerator:4,time_signature_denominator:4,sample_rate:44100,zoom:window.__sfTimelineZoom||window.__currentZoom||1.0},main_session:{id:"main",name:"MAIN SESSION",is_root:true,length_bars:(()=>{let maxBar=16.0;(mainTracks||[]).forEach(t=>{(t.items||[]).forEach(item=>{const end=(item.start_bar||0)+(item.duration_bars||4);if(end>maxBar)maxBar=end;});});return maxBar;})(),auto_compute_length:true,tracks:mainTracks,...(masteringSettings&&Array.isArray(masteringSettings.vstFxChain)&&masteringSettings.vstFxChain.length>0||typeof masterVolume==='number'&&isFinite(masterVolume)&&masterVolume!==0?{master:{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.masterConnected||!!masteringSettings.vstBypass}}:{})},sub_tabs:subTabs,section_store:sectionStore,mastering_settings:masteringSettings?{...masteringSettings,gui_open_path:_persistMasterGuiOpen&&_persistMasterGuiOpen.path?_persistMasterGuiOpen.path:masteringSettings&&masteringSettings.gui_open_path||null,gui_open_name:_persistMasterGuiOpen&&_persistMasterGuiOpen.name?_persistMasterGuiOpen.name:masteringSettings&&masteringSettings.gui_open_name||null}:null};};const deserializeProjectFromSchema=schemaObj=>{// GUI knob captures are per-project - don't leak presets between projects.
_capturedPresetByPath={};const bpmVal=schemaObj.metadata?schemaObj.metadata.bpm:120;const secondsPerBar=60.0/bpmVal*4;const sectionStore=schemaObj.section_store||{};const restoredTracks=deserializeTracksList(schemaObj.main_session.tracks,secondsPerBar,sectionStore);const restoredSessionTabs=[];Object.keys(sectionStore).forEach(secId=>{const secContainer=sectionStore[secId];const secTracks=deserializeTracksList(secContainer.tracks,secondsPerBar,sectionStore);restoredSessionTabs.push({id:'session_'+secId,name:secContainer.name,sectionId:secId,tracks:secTracks,length_bars:secContainer.length_bars||16.0,auto_compute_length:secContainer.auto_compute_length!==undefined?secContainer.auto_compute_length:true,color:secContainer.color||null});});const restoredSubTabs=(schemaObj.sub_tabs||[]).map(st=>{return{id:st.id,label:st.label,type:st.type,trackId:st.track_id,target_id:st.target_id,parent_tab_id:st.parent_tab_id,notes:st.notes||[],duration:st.duration||4,instrumentProgram:st.instrument_program,instrumentName:st.instrument_name,instrumentId:st.instrument_id||st.synth_engine&&st.synth_engine.plugin_id||null,synth_engine:st.synth_engine||null,currentTime:st.current_time||0,color:st.color||null};});// Migrate mastering settings saved with the OLD imager width scale
// (−100..+100, 0 = original width) to the new imager_spec.md scale
// (0..200, 0% = MONO, 100% = original, 200% = double width): old value v
@@ -779,13 +807,13 @@ let _capturedPresetByPath={};const FXRackModal=({track,onUpdateTrack,onClose})=>
const[scopeChannel,setScopeChannel]=React.useState('stereo');const[scopeMode,setScopeMode]=React.useState('waveform');const[scopeDuration,setScopeDuration]=React.useState(2.0);const[scopeZoom,setScopeZoom]=React.useState(0);const[scopePaused,setScopePaused]=React.useState(false);const scopeCanvasRef=React.useRef(null);const scopeMeterLRef=React.useRef(null);const scopeMeterRRef=React.useRef(null);const eqCurveRef=React.useRef(null);const scopeStateRef=React.useRef({L:null,R:null,head:0,len:0,tmpL:null,tmpR:null,freq:null});// VST FX (native_bridge --render-fx) — list VST3 effect plugins + preview
const[fxList,setFxList]=React.useState([]);React.useEffect(()=>{if(window.SonicAPI&&window.SonicAPI.listFx){window.SonicAPI.listFx().then(d=>setFxList(d&&d.plugins||[])).catch(()=>setFxList([]));}},[track&&track.id]);const previewAudioRef=React.useRef(null);const[vstBypass,setVstBypass]=React.useState(!!(track&&track.vstFxBypass));const[activeVstIdx,setActiveVstIdx]=React.useState(-1);const[vstPickerOpen,setVstPickerOpen]=React.useState(false);if(!track)return null;const chain=track.fxChain||[];// ── VST FX chain (native_bridge --render-fx, áp dụng khi Export/Offline render) ──
const vstChain=track.vstFxChain||[];// Unified chain strip: builtin DSP + VST FX chung 1 chain (kéo đổi thứ tự giữa cả 2 loại).
-const unifiedChain=[...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});};// Embedded VST GUI state (PLAN_MASTERBUS_VST_GUI_EMBED.md Phase 3)
+const unifiedChain=[...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});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);};const fxGuiOpenRef=React.useRef(null);fxGuiOpenRef.current=fxGuiOpen;const closeFxGui=()=>{const g=fxGuiOpenRef.current;if(g){try{fetch(g.url+'/hide',{method:'POST'}).catch(()=>{});}catch(e){}}setFxGuiOpen(null);if(onUpdateTrack&&track)onUpdateTrack(track.id,{vstGuiOpenPath:null,vstGuiOpenName:null});};React.useEffect(()=>()=>{const g=fxGuiOpenRef.current;if(g){try{fetch(g.url+'/hide',{method:'POST'}).catch(()=>{});}catch(e){}}},[]);// Capture GUI knob tweaks while the embedded GUI is open (bridge /preset).
React.useEffect(()=>{if(!fxGuiOpen||!fxGuiOpen.url)return;const iv=setInterval(()=>{fetch(fxGuiOpen.url+'/preset').then(r=>r.json()).then(d=>{if(d&&d.preset_b64)_capturedPresetByPath[fxGuiOpen.path]=d.preset_b64;}).catch(()=>{});},1500);return()=>clearInterval(iv);// eslint-disable-next-line react-hooks/exhaustive-deps
},[fxGuiOpen]);// Reopen the saved GUI when the rack is mounted after a project restore.
React.useEffect(()=>{if(!track||!track.vstGuiOpenPath||fxGuiOpen)return;const s=(track.vstFxChain||[]).find(x=>x.path===track.vstGuiOpenPath);if(!s||!window.SonicAPI||!window.SonicAPI.openFxGui)return;window.SonicAPI.openFxGui({path:s.path,name:s.name||s.path,embed:true}).then(r=>{if(r&&r.embed_url)setFxGuiOpen({path:s.path,url:r.embed_url});}).catch(()=>{});// eslint-disable-next-line react-hooks/exhaustive-deps
-},[track&&track.id,track&&track.vstGuiOpenPath]);const toggleVst=idx=>setVstChain(vstChain.map((s,i)=>i===idx?{...s,bypass:!s.bypass}:s));const removeVst=idx=>{const rem=vstChain[idx];if(rem&&fxGuiOpen&&fxGuiOpen.path===rem.path)closeFxGui();if(rem)delete _capturedPresetByPath[rem.path];if(idx===activeVstIdx)setActiveVstIdx(-1);setVstChain(vstChain.filter((_,i)=>i!==idx));};const moveVst=(idx,dir)=>{const j=idx+dir;if(j<0||j>=vstChain.length)return;const next=[...vstChain];const mv=next.splice(idx,1)[0];next.splice(j,0,mv);setVstChain(next);};const openVstGui=(s,idx)=>{if(!s||!s.path){window.showToast&&window.showToast('Chọn VST FX trước','warning');return;}if(fxGuiOpen)closeFxGui();if(idx!==undefined)setActiveVstIdx(idx);if(!window.SonicAPI||!window.SonicAPI.openFxGui){window.showToast&&window.showToast('Máy này không hỗ trợ mở GUI VST','warning');return;}window.SonicAPI.openFxGui({path:s.path,name:s.name||s.path,embed:true}).then(r=>{if(r&&r.embed_url){setFxGuiOpen({path:s.path,url:r.embed_url,name:s.name||s.path});if(onUpdateTrack&&track)onUpdateTrack(track.id,{vstGuiOpenPath:s.path,vstGuiOpenName:s.name||s.path});}else if(r&&r.already_running)window.showToast&&window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)','info');else window.showToast&&window.showToast('Không mở được GUI embed','error');}).catch(err=>window.showToast&&window.showToast('Lỗi mở GUI: '+(err.message||err),'error'));};const addVst=path=>{const fx=(fxList||[]).find(f=>f.path===path);if(!fx)return;const next=[...vstChain,{type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false}];setVstChain(next);setActiveVstIdx(next.length-1);};const changeVstPlugin=(idx,path)=>{const fx=(fxList||[]).find(f=>f.path===path);delete _capturedPresetByPath[path];setVstChain(vstChain.map((s,i)=>i===idx?{...s,path,name:fx?fx.name||fx.id:s.name,preset_b64:''}:s));};const uploadVstPreset=idx=>{const inp=document.createElement('input');inp.type='file';inp.accept='.vstpreset,.fxp,.fxb,.dspreset';inp.onchange=()=>{const f=inp.files&&inp.files[0];if(!f)return;const reader=new FileReader();reader.onload=()=>{const b64=String(reader.result||'').split(',')[1]||'';setVstChain(vstChain.map((s,i)=>i===idx?{...s,preset_b64:b64}:s));window.showToast&&window.showToast('Đã gắn preset .vstpreset vào slot '+(idx+1),'success');};reader.readAsDataURL(f);};inp.click();};const previewFx=async()=>{try{if(previewAudioRef.current){previewAudioRef.current.pause();previewAudioRef.current=null;}if(!window.__renderProjectPreview){window.showToast&&window.showToast('Không có hàm render preview','warning');return;}window.showToast&&window.showToast('Đang render offline (bridge)...','info');const res=await window.__renderProjectPreview();const au=new Audio((window.API_BASE_URL||'')+res.url);previewAudioRef.current=au;au.onended=()=>{previewAudioRef.current=null;};au.play().then(()=>window.showToast&&window.showToast('Preview offline — âm thật = âm export (VST FX)','success')).catch(()=>{});}catch(e){/* toast ở __renderProjectPreview */}};const setChain=next=>{if(onUpdateTrack)onUpdateTrack(track.id,{fxChain:next});if(window.__rebuildTrackFxGraph)window.__rebuildTrackFxGraph(track.id);};const paramsOf=m=>({...(TRACK_FX_DEFAULTS[m.type]||{}),...(m.params||{})});const setParams=(idx,patch)=>{const next=chain.map((m,i)=>i===idx?{...m,params:{...paramsOf(m),...patch}}:m);setChain(next);};const toggleMod=idx=>{const next=chain.map((m,i)=>i===idx?{...m,active:!(m.active!==false)}:m);setChain(next);};const removeMod=idx=>setChain(chain.filter((_,i)=>i!==idx));const addMod=type=>{const def=TRACK_FX_DEFAULTS[type]||{};// Deep-clone default params so each chain entry owns its data (bands array
+},[track&&track.id,track&&track.vstGuiOpenPath]);const toggleVst=idx=>setVstChain(vstChain.map((s,i)=>i===idx?{...s,bypass:!s.bypass}:s));const removeVst=idx=>{const rem=vstChain[idx];if(rem&&fxGuiOpen&&fxGuiOpen.path===rem.path)closeFxGui();if(rem)delete _capturedPresetByPath[rem.path];if(idx===activeVstIdx)setActiveVstIdx(-1);setVstChain(vstChain.filter((_,i)=>i!==idx));};const moveVst=(idx,dir)=>{const j=idx+dir;if(j<0||j>=vstChain.length)return;const next=[...vstChain];const mv=next.splice(idx,1)[0];next.splice(j,0,mv);setVstChain(next);};const openVstGui=(s,idx)=>{if(!s||!s.path){window.showToast&&window.showToast('Chọn VST FX trước','warning');return;}if(fxGuiOpen)closeFxGui();if(idx!==undefined)setActiveVstIdx(idx);if(!window.SonicAPI||!window.SonicAPI.openFxGui){window.showToast&&window.showToast('Máy này không hỗ trợ mở GUI VST','warning');return;}window.SonicAPI.openFxGui({path:s.path,name:s.name||s.path,embed:true}).then(r=>{if(r&&r.embed_url){setFxGuiOpen({path:s.path,url:r.embed_url,name:s.name||s.path});if(onUpdateTrack&&track)onUpdateTrack(track.id,{vstGuiOpenPath:s.path,vstGuiOpenName:s.name||s.path});}else if(r&&r.already_running)window.showToast&&window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)','info');else window.showToast&&window.showToast('Không mở được GUI embed','error');}).catch(err=>window.showToast&&window.showToast('Lỗi mở GUI: '+(err.message||err),'error'));};const addVst=path=>{const fx=(fxList||[]).find(f=>f.path===path);if(!fx)return;const next=[...vstChain,{type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false}];setVstChain(next);setActiveVstIdx(next.length-1);};const changeVstPlugin=(idx,path)=>{const fx=(fxList||[]).find(f=>f.path===path);delete _capturedPresetByPath[path];setVstChain(vstChain.map((s,i)=>i===idx?{...s,path,name:fx?fx.name||fx.id:s.name,preset_b64:''}:s));};const uploadVstPreset=idx=>{const inp=document.createElement('input');inp.type='file';inp.accept='.vstpreset,.fxp,.fxb,.dspreset';inp.onchange=()=>{const f=inp.files&&inp.files[0];if(!f)return;const reader=new FileReader();reader.onload=()=>{const b64=String(reader.result||'').split(',')[1]||'';setVstChain(vstChain.map((s,i)=>i===idx?{...s,preset_b64:b64}:s));window.showToast&&window.showToast('Đã gắn preset .vstpreset vào slot '+(idx+1),'success');};reader.readAsDataURL(f);};inp.click();};const previewFx=async()=>{try{if(previewAudioRef.current){previewAudioRef.current.pause();previewAudioRef.current=null;}if(!window.__renderProjectPreview){window.showToast&&window.showToast('Không có hàm render preview','warning');return;}window.showToast&&window.showToast('Đang render offline (bridge)...','info');const res=await window.__renderProjectPreview();const au=new Audio((window.API_BASE_URL||'')+res.url);previewAudioRef.current=au;au.onended=()=>{previewAudioRef.current=null;};au.play().then(()=>window.showToast&&window.showToast('Preview offline — âm thật = âm export (VST FX)','success')).catch(()=>{});}catch(e){/* toast ở __renderProjectPreview */}};const setChain=next=>{if(onUpdateTrack)onUpdateTrack(track.id,{fxChain:next});if(window.__rebuildTrackFxGraph)window.__rebuildTrackFxGraph(track.id,{fxChain:next});};const paramsOf=m=>({...(TRACK_FX_DEFAULTS[m.type]||{}),...(m.params||{})});const setParams=(idx,patch)=>{const next=chain.map((m,i)=>i===idx?{...m,params:{...paramsOf(m),...patch}}:m);setChain(next);};const toggleMod=idx=>{const next=chain.map((m,i)=>i===idx?{...m,active:!(m.active!==false)}:m);setChain(next);};const removeMod=idx=>setChain(chain.filter((_,i)=>i!==idx));const addMod=type=>{const def=TRACK_FX_DEFAULTS[type]||{};// Deep-clone default params so each chain entry owns its data (bands array
// especially — shared references would corrupt other modules' state).
const params=Array.isArray(def.bands)?{...def,bands:JSON.parse(JSON.stringify(def.bands))}:{...def};setChain([...chain,{type,active:true,params}]);setActiveType(type);setActiveVstIdx(-1);setAddModuleOpen(false);};const applyPreset=key=>{const preset=TRACK_EQ_PRESETS[key];if(!preset)return;const idx=chain.findIndex(m=>m.type==='eq'&&m.active!==false);if(idx>=0)setParams(idx,{g1:preset.g[0],g2:preset.g[1],g3:preset.g[2],g4:preset.g[3]});};const activeMod=chain.find(m=>m.type===activeType)||chain[chain.length-1]||null;const activeIdx=chain.findIndex(m=>m===activeMod);const ap=activeMod?paramsOf(activeMod):{};const slider=(label,val,min,max,step,color,onChange,fmt)=>/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between text-[11px] font-mono"},/*#__PURE__*/React.createElement("span",{style:{color,fontWeight:700}},label),/*#__PURE__*/React.createElement("span",{className:"text-slate-300"},fmt?fmt(val):val)),/*#__PURE__*/React.createElement("input",{type:"range",min:min,max:max,step:step||0.1,value:val,onChange:e=>onChange(parseFloat(e.target.value)),className:"w-full h-1 cursor-pointer",style:{accentColor:color}}));// ── Wave Observer real-time scope render loop ──
React.useEffect(()=>{const canvas=scopeCanvasRef.current;if(!canvas||!track)return;const st=scopeStateRef.current;let raf=null;const draw=()=>{raf=requestAnimationFrame(draw);const w=canvas.width,h=canvas.height;const ctx=canvas.getContext('2d');ctx.clearRect(0,0,w,h);// Grid
@@ -798,7 +826,7 @@ ctx.strokeStyle='rgba(51, 65, 85, 0.35)';for(let i=1;i<5;i++){const y=i/5*h;ctx.
const bands=[{type:'lowshelf',f0:100,gain:ap.g1||0,q:0.7},{type:'peaking',f0:800,gain:ap.g2||0,q:0.7},{type:'peaking',f0:3200,gain:ap.g3||0,q:1.2},{type:'highshelf',f0:10000,gain:ap.g4||0,q:0.7}];const pts=[];const N=120;for(let i=0;i<=N;i++){const f=20*Math.pow(20000/20,i/N);let db=0;bands.forEach(b=>{const lf=Math.log(f/b.f0);if(b.type==='peaking'){db+=b.gain/(1+Math.pow(lf*b.q,2));}else if(b.type==='highshelf'){db+=b.gain/2*(1+2/Math.PI*Math.atan(lf/(1/b.q)));}else{db+=b.gain/2*(1-2/Math.PI*Math.atan(lf/(1/b.q)));}});const x=i/N*w;const y=midY-db/12*(h/2);pts.push([x,y]);}// fill
ctx.beginPath();pts.forEach(([x,y],i)=>i===0?ctx.moveTo(x,y):ctx.lineTo(x,y));ctx.lineTo(w,h);ctx.lineTo(0,h);ctx.closePath();ctx.fillStyle='rgba(34, 211, 238, 0.10)';ctx.fill();// curve
ctx.beginPath();pts.forEach(([x,y],i)=>i===0?ctx.moveTo(x,y):ctx.lineTo(x,y));ctx.strokeStyle='#22d3ee';ctx.lineWidth=2;ctx.stroke();// dB labels
-ctx.fillStyle='#475569';ctx.font='9px monospace';ctx.fillText('+12 dB',4,midY-h/2+10);ctx.fillText('0 dB',4,midY+3);ctx.fillText('-12 dB',4,midY+h/2-4);ctx.fillText('20Hz',4,h-2);ctx.fillText('20kHz',w-38,h-2);},[ap.g1,ap.g2,ap.g3,ap.g4,activeMod]);return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[115] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4",onClick:onClose},/*#__PURE__*/React.createElement("div",{className:"w-full max-w-4xl bg-slate-900 border border-slate-700 rounded-2xl p-5 space-y-4 shadow-2xl flex flex-col max-h-[90vh] text-slate-200",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-800 pb-3"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("div",{className:"w-3 h-3 rounded-full bg-cyan-400 animate-pulse"}),/*#__PURE__*/React.createElement("h3",{className:"text-xs font-bold text-white uppercase tracking-wider font-mono"},track.name," — FX CHAIN")),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-white text-base"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("div",{className:"h-16 bg-slate-950 border border-slate-800 rounded-xl px-3 flex items-center gap-2 overflow-x-auto shrink-0 select-none"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-500 uppercase tracking-widest mr-1 font-mono shrink-0"},"CHAIN:"),unifiedChain.length===0&&/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 font-mono"},"Chưa có FX — bấm [+] (DSP) hoặc [+VST] để thêm (signal đi thẳng: Source → Fader)"),unifiedChain.map((u,ui)=>{if(u.kind==='builtin'){const m=u.m;const meta=TRACK_FX_META[m.type]||{name:m.type,icon:'circle',color:'#94a3b8',sub:''};const on=m.active!==false;return/*#__PURE__*/React.createElement("div",{key:`b${u.i}`,draggable:true,onDragStart:e=>{dragChainIndexRef.current=ui;e.dataTransfer.effectAllowed='move';},onDragOver:e=>{e.preventDefault();e.dataTransfer.dropEffect='move';},onDrop:e=>{e.preventDefault();const from=dragChainIndexRef.current;if(from!==null&&from!==ui){const next=[...unifiedChain];const mv=next.splice(from,1)[0];next.splice(ui,0,mv);const nb=next.filter(x=>x.kind==='builtin').map(x=>x.m);const nv=next.filter(x=>x.kind==='vst').map(x=>x.s);setChain(nb);setVstChain(nv);if(mv.kind==='vst')setActiveVstIdx(nv.indexOf(mv.s));}dragChainIndexRef.current=null;},onClick:()=>{closeFxGui();setActiveType(m.type);},className:`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all shrink-0 ${activeType===m.type?'border-2 border-cyan-400 bg-slate-800':'bg-slate-900 border border-slate-800 hover:border-slate-600'}`},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 min-w-0"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleMod(u.i);},className:"w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0",style:{backgroundColor:on?'#38bdf8':'#334155',color:on?'#0f172a':'#94a3b8'}},/*#__PURE__*/React.createElement("i",{"data-lucide":"power",className:"w-2.5 h-2.5"})),/*#__PURE__*/React.createElement("div",{className:"min-w-0"},/*#__PURE__*/React.createElement("div",{className:"text-[11px] font-bold text-slate-200 truncate"},meta.name),/*#__PURE__*/React.createElement("div",{className:"text-[9px] font-mono truncate",style:{color:meta.color}},u.i+1,". ",meta.sub))),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();removeMod(u.i);},className:"text-slate-600 hover:text-red-400 text-xs px-0.5 shrink-0",title:"Xóa module"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})));}const s=u.s;return/*#__PURE__*/React.createElement("div",{key:`v${u.i}`,draggable:true,onDragStart:e=>{dragChainIndexRef.current=ui;e.dataTransfer.effectAllowed='move';},onDragOver:e=>{e.preventDefault();e.dataTransfer.dropEffect='move';},onDrop:e=>{e.preventDefault();const from=dragChainIndexRef.current;if(from!==null&&from!==ui){const next=[...unifiedChain];const mv=next.splice(from,1)[0];next.splice(ui,0,mv);const nb=next.filter(x=>x.kind==='builtin').map(x=>x.m);const nv=next.filter(x=>x.kind==='vst').map(x=>x.s);setChain(nb);setVstChain(nv);if(mv.kind==='vst')setActiveVstIdx(nv.indexOf(mv.s));}dragChainIndexRef.current=null;},onClick:()=>{closeFxGui();setActiveVstIdx(u.i);},className:`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all shrink-0 ${activeVstIdx===u.i?'border-2 border-teal-400 bg-slate-800':'bg-slate-900 border border-teal-900/70 hover:border-teal-600'}`},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 min-w-0"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleVst(u.i);},className:"w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0",style:{backgroundColor:!s.bypass?'#2dd4bf':'#334155',color:!s.bypass?'#0f172a':'#94a3b8'}},/*#__PURE__*/React.createElement("i",{"data-lucide":"power",className:"w-2.5 h-2.5"})),/*#__PURE__*/React.createElement("div",{className:"min-w-0"},/*#__PURE__*/React.createElement("div",{className:"text-[11px] font-bold text-teal-200 truncate"},s.name||s.path),/*#__PURE__*/React.createElement("div",{className:"text-[9px] font-mono truncate text-teal-500/80"},u.i+1,". ",s.preset_b64?'✓ preset':'no preset'))),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();removeVst(u.i);},className:"text-slate-600 hover:text-red-400 text-xs px-0.5 shrink-0",title:"Xóa slot"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})));}),/*#__PURE__*/React.createElement("button",{onClick:()=>setAddModuleOpen(true),className:"w-16 h-12 rounded-lg border border-dashed border-slate-700 hover:border-cyan-500 flex items-center justify-center text-slate-500 hover:text-cyan-400 cursor-pointer transition-all bg-slate-900/40 shrink-0",title:"Thêm module DSP builtin"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-4 h-4"})),/*#__PURE__*/React.createElement("button",{onClick:()=>setVstPickerOpen(true),className:"w-16 h-12 rounded-lg border border-dashed border-teal-800 hover:border-teal-500 flex items-center justify-center text-teal-500 hover:text-teal-300 cursor-pointer transition-all bg-slate-900/40 shrink-0",title:"Thêm VST FX (Plugin Picker theo category)"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plug",className:"w-3.5 h-3.5"}),/*#__PURE__*/React.createElement("span",{className:"text-[9px] font-bold font-mono ml-0.5"},"VST"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between flex-wrap gap-2 shrink-0"},/*#__PURE__*/React.createElement("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"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 shrink-0"},/*#__PURE__*/React.createElement("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"},/*#__PURE__*/React.createElement("i",{"data-lucide":"play",className:"w-3 h-3"})," Nghe thử (render offline)"),/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1 text-[10px] text-slate-400 font-mono cursor-pointer"},/*#__PURE__*/React.createElement("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"))),fxGuiOpen?/*#__PURE__*/React.createElement("div",{className:"flex-1 min-h-0"},/*#__PURE__*/React.createElement(VstGuiEmbed,{url:fxGuiOpen.url,onClose:closeFxGui,onDead:()=>{window.showToast&&window.showToast('Bridge VST GUI đã mất kết nối — trả về graph','error');closeFxGui();}})):/*#__PURE__*/React.createElement(React.Fragment,null,activeVstIdx>=0&&activeVstIdxapplyPreset(e.target.value),className:"bg-slate-950 border border-slate-700 text-cyan-300 rounded px-2 py-0.5 text-xs outline-none focus:border-cyan-500"},Object.keys(TRACK_EQ_PRESETS).map(k=>/*#__PURE__*/React.createElement("option",{key:k,value:k},TRACK_EQ_PRESETS[k].name))))),!activeMod&&/*#__PURE__*/React.createElement("div",{className:"text-xs text-slate-500 font-mono text-center py-10"},"Chưa có module. Bấm [+] để thêm EQ / Compressor / Limiter / Exciter / Rebalance."),activeMod&&activeMod.type==='eq'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"space-y-3"},/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-4 gap-4"},[['g1','BAND 1 (LOW)','100 Hz','#22d3ee'],['g2','BAND 2 (MID LOW)','800 Hz','#fbbf24'],['g3','BAND 3 (MID HIGH)','3.2 kHz','#a855f7'],['g4','BAND 4 (HIGH)','10 kHz','#34d399']].map(([key,label,freq,color])=>/*#__PURE__*/React.createElement("div",{key:key,className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg space-y-2"},slider(label,ap[key]!==undefined?ap[key]:0,-12,12,0.1,color,v=>setParams(activeIdx,{[key]:v}),v=>`${v>0?'+':''}${v.toFixed(1)} dB`),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 font-mono text-center"},freq)))),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 rounded-lg p-2"},/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 font-mono mb-1 flex justify-between"},/*#__PURE__*/React.createElement("span",null,"VECTOR DISPLAY — EQ RESPONSE"),/*#__PURE__*/React.createElement("span",null,"20Hz – 20kHz")),/*#__PURE__*/React.createElement("canvas",{ref:eqCurveRef,className:"w-full h-[88px] block"}))),activeMod&&activeMod.type==='eqpro'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"space-y-3"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between"},/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-teal-400 font-mono uppercase tracking-widest"},"PARAMETRIC / GRAPHIC EQ PRO — Pro-Q style interactive"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500 font-mono"},"20Hz – 20kHz · ±24dB · ",EQPRO_MAX_BANDS," bands max")),/*#__PURE__*/React.createElement(InteractiveEqPro,{track:track,params:ap,onChange:next=>setParams(activeIdx,next),getModule:()=>window.__getTrackFxModule?window.__getTrackFxModule(track.id,'eqpro'):null,spectrumModules:()=>{const a=window.__getTrackFxModule?window.__getTrackFxModule(track.id,'eqpro'):null;const b=window.__getTrackSfFxModule?window.__getTrackSfFxModule(track.id,'eqpro'):null;return[a,b].filter(Boolean);}})),activeMod&&activeMod.type==='compressor'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('THRESHOLD',ap.threshold,-60,0,0.5,'#fbbf24',v=>setParams(activeIdx,{threshold:v}),v=>`${v.toFixed(1)} dB`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('RATIO',ap.ratio,1,20,0.5,'#f59e0b',v=>setParams(activeIdx,{ratio:v}),v=>`${v.toFixed(1)} : 1`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('MAKE-UP',ap.makeup,0,12,0.1,'#f59e0b',v=>setParams(activeIdx,{makeup:v}),v=>`${v.toFixed(1)} dB`))),activeMod&&activeMod.type==='limiter'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('CEILING',ap.ceiling,-24,0,0.1,'#f43f5e',v=>setParams(activeIdx,{ceiling:v}),v=>`${v.toFixed(1)} dB`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg flex items-center justify-center text-[10px] text-slate-500 font-mono text-center leading-snug"},"True-Peak limiting",/*#__PURE__*/React.createElement("br",null),"Ratio 20:1 · Knee 0dB")),activeMod&&activeMod.type==='exciter'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('DRIVE / MIX',ap.drive,0,100,1,'#c084fc',v=>setParams(activeIdx,{drive:v}),v=>`${v}%`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg flex items-center justify-center text-[10px] text-slate-500 font-mono text-center leading-snug"},"WaveShaper saturation",/*#__PURE__*/React.createElement("br",null),"High-pass 2kHz · 4× oversampled")),activeMod&&activeMod.type==='rebalance'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('MID GAIN',ap.mid,-12,12,0.1,'#38bdf8',v=>setParams(activeIdx,{mid:v}),v=>`${v>0?'+':''}${v.toFixed(1)} dB`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('SIDE GAIN',ap.side,-12,12,0.1,'#22d3ee',v=>setParams(activeIdx,{side:v}),v=>`${v>0?'+':''}${v.toFixed(1)} dB`))),activeMod&&activeMod.type==='carla'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"bg-amber-950/40 border border-amber-800/60 rounded-lg p-4 space-y-3"},/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-amber-300 font-mono"},"Carla Bridge đã bị gỡ khỏi FX Rack — track cũ vẫn giữ module để không mất cấu hình."),/*#__PURE__*/React.createElement("button",{onClick:()=>removeMod(activeIdx),className:"px-3 py-1.5 bg-amber-800 hover:bg-amber-700 text-amber-50 text-xs font-semibold rounded transition"},"Gỡ module Carla khỏi track"))),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800 rounded-xl p-3 flex flex-col gap-2 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-xs font-mono border-b border-slate-800/80 pb-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"font-bold text-white tracking-wider"},"WAVE OBSERVER"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] bg-cyan-950 border border-cyan-800 text-cyan-400 px-1.5 py-0.5 rounded"},"Real-time Oscilloscope")),/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 truncate max-w-[200px]"},"Context: ",track.name)),/*#__PURE__*/React.createElement("div",{className:"relative w-full h-36 bg-slate-950 border border-slate-900 rounded-lg overflow-hidden"},/*#__PURE__*/React.createElement("canvas",{ref:scopeCanvasRef,className:"w-full h-full block cursor-crosshair"})),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between bg-slate-900/90 border border-slate-800 rounded-lg px-3 py-1.5 text-xs font-mono flex-wrap gap-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 pr-3 border-r border-slate-800"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-400"},"Input"),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1 w-12"},/*#__PURE__*/React.createElement("div",{className:"h-1.5 bg-slate-950 rounded overflow-hidden flex"},/*#__PURE__*/React.createElement("div",{ref:scopeMeterLRef,className:"h-full bg-cyan-400 w-0 transition-all"})),/*#__PURE__*/React.createElement("div",{className:"h-1.5 bg-slate-950 rounded overflow-hidden flex"},/*#__PURE__*/React.createElement("div",{ref:scopeMeterRRef,className:"h-full bg-cyan-400 w-0 transition-all"})))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-500 text-[10px]"},"Channel:"),/*#__PURE__*/React.createElement("select",{value:scopeChannel,onChange:e=>setScopeChannel(e.target.value),className:"bg-slate-950 border border-slate-800 text-slate-300 rounded px-1.5 py-0.5 text-[11px] outline-none"},/*#__PURE__*/React.createElement("option",{value:"stereo"},"Stereo"),/*#__PURE__*/React.createElement("option",{value:"left"},"Left"),/*#__PURE__*/React.createElement("option",{value:"right"},"Right"),/*#__PURE__*/React.createElement("option",{value:"mid"},"Mid"),/*#__PURE__*/React.createElement("option",{value:"side"},"Side"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-500 text-[10px]"},"Mode:"),/*#__PURE__*/React.createElement("select",{value:scopeMode,onChange:e=>setScopeMode(e.target.value),className:"bg-slate-950 border border-slate-800 text-slate-300 rounded px-1.5 py-0.5 text-[11px] outline-none"},/*#__PURE__*/React.createElement("option",{value:"waveform"},"Waveform"),/*#__PURE__*/React.createElement("option",{value:"lissajous"},"Lissajous"),/*#__PURE__*/React.createElement("option",{value:"spectrum"},"Spectrum"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-500 text-[10px]"},"Duration:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold text-[11px] w-12"},scopeDuration.toFixed(2),"s"),/*#__PURE__*/React.createElement("input",{type:"range",min:"0.1",max:"5.0",step:"0.1",value:scopeDuration,onChange:e=>setScopeDuration(parseFloat(e.target.value)),className:"w-16 h-1 cursor-pointer",style:{accentColor:'#38bdf8'}})),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-500 text-[10px]"},"V.Zoom:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold text-[11px] w-12"},scopeZoom>0?'+':'',scopeZoom.toFixed(1)," dB"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"20",step:"0.5",value:scopeZoom,onChange:e=>setScopeZoom(parseFloat(e.target.value)),className:"w-16 h-1 cursor-pointer",style:{accentColor:'#38bdf8'}})),/*#__PURE__*/React.createElement("button",{onClick:()=>setScopePaused(p=>!p),className:`px-3 py-1 font-semibold rounded text-[11px] border transition-colors ${scopePaused?'bg-amber-600 border-amber-400 text-white':'bg-slate-800 hover:bg-slate-700 text-slate-300 border-slate-700'}`},scopePaused?'Resume':'Pause'))))),addModuleOpen&&/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[120] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4",onClick:()=>setAddModuleOpen(false)},/*#__PURE__*/React.createElement("div",{className:"w-full max-w-lg bg-slate-900 border border-slate-700 rounded-2xl p-5 space-y-4 shadow-2xl max-h-[80vh] overflow-y-auto",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-800 pb-3"},/*#__PURE__*/React.createElement("h3",{className:"text-xs font-bold text-white uppercase tracking-wider font-mono"},"THÊM MODULE VÀO FX CHAIN"),/*#__PURE__*/React.createElement("button",{onClick:()=>setAddModuleOpen(false),className:"text-slate-400 hover:text-white"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-3 text-xs"},Object.keys(TRACK_FX_META).map(t=>{const meta=TRACK_FX_META[t];return/*#__PURE__*/React.createElement("button",{key:t,onClick:()=>addMod(t),className:"p-3 bg-slate-950 hover:bg-slate-800 border border-slate-800 rounded-xl text-left space-y-1 transition-colors"},/*#__PURE__*/React.createElement("div",{className:"font-bold flex items-center gap-1.5",style:{color:meta.color}},/*#__PURE__*/React.createElement("i",{"data-lucide":meta.icon,className:"w-3.5 h-3.5"})," ",meta.name),/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-slate-400"},meta.sub));})))),/*#__PURE__*/React.createElement(VstPluginPicker,{open:vstPickerOpen,onClose:()=>setVstPickerOpen(false),plugins:fxList,onPick:f=>addVst(f.path)}));};// ──────────────────────────────────────────────
+ctx.fillStyle='#475569';ctx.font='9px monospace';ctx.fillText('+12 dB',4,midY-h/2+10);ctx.fillText('0 dB',4,midY+3);ctx.fillText('-12 dB',4,midY+h/2-4);ctx.fillText('20Hz',4,h-2);ctx.fillText('20kHz',w-38,h-2);},[ap.g1,ap.g2,ap.g3,ap.g4,activeMod]);return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[115] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4",onClick:onClose},/*#__PURE__*/React.createElement("div",{className:"w-full max-w-4xl bg-slate-900 border border-slate-700 rounded-2xl p-5 space-y-4 shadow-2xl flex flex-col max-h-[90vh] text-slate-200",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-800 pb-3"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("div",{className:"w-3 h-3 rounded-full bg-cyan-400 animate-pulse"}),/*#__PURE__*/React.createElement("h3",{className:"text-xs font-bold text-white uppercase tracking-wider font-mono"},track.name," — FX CHAIN")),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-400 hover:text-white text-base"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("div",{className:"h-16 bg-slate-950 border border-slate-800 rounded-xl px-3 flex items-center gap-2 overflow-x-auto shrink-0 select-none"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-500 uppercase tracking-widest mr-1 font-mono shrink-0"},"CHAIN:"),unifiedChain.length===0&&/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 font-mono"},"Chưa có FX — bấm [+] (DSP) hoặc [+VST] để thêm (signal đi thẳng: Source → Fader)"),unifiedChain.map((u,ui)=>{if(u.kind==='builtin'){const m=u.m;const meta=TRACK_FX_META[m.type]||{name:m.type,icon:'circle',color:'#94a3b8',sub:''};const on=m.active!==false;return/*#__PURE__*/React.createElement("div",{key:`b${u.i}`,draggable:true,onDragStart:e=>{dragChainIndexRef.current=ui;e.dataTransfer.effectAllowed='move';},onDragOver:e=>{e.preventDefault();e.dataTransfer.dropEffect='move';},onDrop:e=>{e.preventDefault();const from=dragChainIndexRef.current;if(from!==null&&from!==ui){const next=[...unifiedChain];const mv=next.splice(from,1)[0];next.splice(ui,0,mv);const nb=next.filter(x=>x.kind==='builtin').map(x=>x.m);const nv=next.filter(x=>x.kind==='vst').map(x=>x.s);setChain(nb);setVstChain(nv);if(mv.kind==='vst')setActiveVstIdx(nv.indexOf(mv.s));}dragChainIndexRef.current=null;},onClick:()=>{closeFxGui();setActiveType(m.type);},className:`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all shrink-0 ${activeType===m.type?'border-2 border-cyan-400 bg-slate-800':'bg-slate-900 border border-slate-800 hover:border-slate-600'}`},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 min-w-0"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleMod(u.i);},className:"w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0",style:{backgroundColor:on?'#38bdf8':'#334155',color:on?'#0f172a':'#94a3b8'}},/*#__PURE__*/React.createElement("i",{"data-lucide":"power",className:"w-2.5 h-2.5"})),/*#__PURE__*/React.createElement("div",{className:"min-w-0"},/*#__PURE__*/React.createElement("div",{className:"text-[11px] font-bold text-slate-200 truncate"},meta.name),/*#__PURE__*/React.createElement("div",{className:"text-[9px] font-mono truncate",style:{color:meta.color}},u.i+1,". ",meta.sub))),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();removeMod(u.i);},className:"text-slate-600 hover:text-red-400 text-xs px-0.5 shrink-0",title:"Xóa module"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})));}const s=u.s;return/*#__PURE__*/React.createElement("div",{key:`v${u.i}`,draggable:true,onDragStart:e=>{dragChainIndexRef.current=ui;e.dataTransfer.effectAllowed='move';},onDragOver:e=>{e.preventDefault();e.dataTransfer.dropEffect='move';},onDrop:e=>{e.preventDefault();const from=dragChainIndexRef.current;if(from!==null&&from!==ui){const next=[...unifiedChain];const mv=next.splice(from,1)[0];next.splice(ui,0,mv);const nb=next.filter(x=>x.kind==='builtin').map(x=>x.m);const nv=next.filter(x=>x.kind==='vst').map(x=>x.s);setChain(nb);setVstChain(nv);if(mv.kind==='vst')setActiveVstIdx(nv.indexOf(mv.s));}dragChainIndexRef.current=null;},onClick:()=>{closeFxGui();setActiveVstIdx(u.i);},className:`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all shrink-0 ${activeVstIdx===u.i?'border-2 border-teal-400 bg-slate-800':'bg-slate-900 border border-teal-900/70 hover:border-teal-600'}`},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 min-w-0"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleVst(u.i);},className:"w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0",style:{backgroundColor:!s.bypass?'#2dd4bf':'#334155',color:!s.bypass?'#0f172a':'#94a3b8'}},/*#__PURE__*/React.createElement("i",{"data-lucide":"power",className:"w-2.5 h-2.5"})),/*#__PURE__*/React.createElement("div",{className:"min-w-0"},/*#__PURE__*/React.createElement("div",{className:"text-[11px] font-bold text-teal-200 truncate"},s.name||s.path),/*#__PURE__*/React.createElement("div",{className:"text-[9px] font-mono truncate text-teal-500/80"},u.i+1,". ",s.preset_b64?'✓ preset':'no preset'))),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();removeVst(u.i);},className:"text-slate-600 hover:text-red-400 text-xs px-0.5 shrink-0",title:"Xóa slot"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})));}),/*#__PURE__*/React.createElement("button",{onClick:()=>setAddModuleOpen(true),className:"w-16 h-12 rounded-lg border border-dashed border-slate-700 hover:border-cyan-500 flex items-center justify-center text-slate-500 hover:text-cyan-400 cursor-pointer transition-all bg-slate-900/40 shrink-0",title:"Thêm module DSP builtin"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-4 h-4"})),/*#__PURE__*/React.createElement("button",{onClick:()=>setVstPickerOpen(true),className:"w-16 h-12 rounded-lg border border-dashed border-teal-800 hover:border-teal-500 flex items-center justify-center text-teal-500 hover:text-teal-300 cursor-pointer transition-all bg-slate-900/40 shrink-0",title:"Thêm VST FX (Plugin Picker theo category)"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plug",className:"w-3.5 h-3.5"}),/*#__PURE__*/React.createElement("span",{className:"text-[9px] font-bold font-mono ml-0.5"},"VST"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between flex-wrap gap-2 shrink-0"},/*#__PURE__*/React.createElement("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"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 shrink-0"},/*#__PURE__*/React.createElement("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"},/*#__PURE__*/React.createElement("i",{"data-lucide":"play",className:"w-3 h-3"})," Nghe thử (render offline)"),/*#__PURE__*/React.createElement("label",{className:"flex items-center gap-1 text-[10px] text-slate-400 font-mono cursor-pointer"},/*#__PURE__*/React.createElement("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"))),fxGuiOpen?/*#__PURE__*/React.createElement("div",{className:"flex-1 min-h-0"},/*#__PURE__*/React.createElement(VstGuiEmbed,{url:fxGuiOpen.url,onClose:closeFxGui,onDead:()=>{window.showToast&&window.showToast('Bridge VST GUI đã mất kết nối — trả về graph','error');closeFxGui();}})):/*#__PURE__*/React.createElement(React.Fragment,null,activeVstIdx>=0&&activeVstIdxapplyPreset(e.target.value),className:"bg-slate-950 border border-slate-700 text-cyan-300 rounded px-2 py-0.5 text-xs outline-none focus:border-cyan-500"},Object.keys(TRACK_EQ_PRESETS).map(k=>/*#__PURE__*/React.createElement("option",{key:k,value:k},TRACK_EQ_PRESETS[k].name))))),!activeMod&&/*#__PURE__*/React.createElement("div",{className:"text-xs text-slate-500 font-mono text-center py-10"},"Chưa có module. Bấm [+] để thêm EQ / Compressor / Limiter / Exciter / Rebalance."),activeMod&&activeMod.type==='eq'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"space-y-3"},/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-4 gap-4"},[['g1','BAND 1 (LOW)','100 Hz','#22d3ee'],['g2','BAND 2 (MID LOW)','800 Hz','#fbbf24'],['g3','BAND 3 (MID HIGH)','3.2 kHz','#a855f7'],['g4','BAND 4 (HIGH)','10 kHz','#34d399']].map(([key,label,freq,color])=>/*#__PURE__*/React.createElement("div",{key:key,className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg space-y-2"},slider(label,ap[key]!==undefined?ap[key]:0,-12,12,0.1,color,v=>setParams(activeIdx,{[key]:v}),v=>`${v>0?'+':''}${v.toFixed(1)} dB`),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 font-mono text-center"},freq)))),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 rounded-lg p-2"},/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 font-mono mb-1 flex justify-between"},/*#__PURE__*/React.createElement("span",null,"VECTOR DISPLAY — EQ RESPONSE"),/*#__PURE__*/React.createElement("span",null,"20Hz – 20kHz")),/*#__PURE__*/React.createElement("canvas",{ref:eqCurveRef,className:"w-full h-[88px] block"}))),activeMod&&activeMod.type==='eqpro'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"space-y-3"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between"},/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-teal-400 font-mono uppercase tracking-widest"},"PARAMETRIC / GRAPHIC EQ PRO — Pro-Q style interactive"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500 font-mono"},"20Hz – 20kHz · ±24dB · ",EQPRO_MAX_BANDS," bands max")),/*#__PURE__*/React.createElement(InteractiveEqPro,{track:track,params:ap,onChange:next=>setParams(activeIdx,next),getModule:()=>window.__getTrackFxModule?window.__getTrackFxModule(track.id,'eqpro'):null,spectrumModules:()=>{const a=window.__getTrackFxModule?window.__getTrackFxModule(track.id,'eqpro'):null;const b=window.__getTrackSfFxModule?window.__getTrackSfFxModule(track.id,'eqpro'):null;return[a,b].filter(Boolean);}})),activeMod&&activeMod.type==='compressor'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('THRESHOLD',ap.threshold,-60,0,0.5,'#fbbf24',v=>setParams(activeIdx,{threshold:v}),v=>`${v.toFixed(1)} dB`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('RATIO',ap.ratio,1,20,0.5,'#f59e0b',v=>setParams(activeIdx,{ratio:v}),v=>`${v.toFixed(1)} : 1`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('MAKE-UP',ap.makeup,0,12,0.1,'#f59e0b',v=>setParams(activeIdx,{makeup:v}),v=>`${v.toFixed(1)} dB`))),activeMod&&activeMod.type==='limiter'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('CEILING',ap.ceiling,-24,0,0.1,'#f43f5e',v=>setParams(activeIdx,{ceiling:v}),v=>`${v.toFixed(1)} dB`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg flex items-center justify-center text-[10px] text-slate-500 font-mono text-center leading-snug"},"True-Peak limiting",/*#__PURE__*/React.createElement("br",null),"Ratio 20:1 · Knee 0dB")),activeMod&&activeMod.type==='exciter'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('DRIVE / MIX',ap.drive,0,100,1,'#c084fc',v=>setParams(activeIdx,{drive:v}),v=>`${v}%`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg flex items-center justify-center text-[10px] text-slate-500 font-mono text-center leading-snug"},"WaveShaper saturation",/*#__PURE__*/React.createElement("br",null),"High-pass 2kHz · 4× oversampled")),activeMod&&activeMod.type==='rebalance'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('MID GAIN',ap.mid,-12,12,0.1,'#38bdf8',v=>setParams(activeIdx,{mid:v}),v=>`${v>0?'+':''}${v.toFixed(1)} dB`)),/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-3 rounded-lg"},slider('SIDE GAIN',ap.side,-12,12,0.1,'#22d3ee',v=>setParams(activeIdx,{side:v}),v=>`${v>0?'+':''}${v.toFixed(1)} dB`))),activeMod&&activeMod.type==='carla'&&activeIdx>=0&&/*#__PURE__*/React.createElement("div",{className:"bg-amber-950/40 border border-amber-800/60 rounded-lg p-4 space-y-3"},/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-amber-300 font-mono"},"Carla Bridge đã bị gỡ khỏi FX Rack — track cũ vẫn giữ module để không mất cấu hình."),/*#__PURE__*/React.createElement("button",{onClick:()=>removeMod(activeIdx),className:"px-3 py-1.5 bg-amber-800 hover:bg-amber-700 text-amber-50 text-xs font-semibold rounded transition"},"Gỡ module Carla khỏi track"))),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800 rounded-xl p-3 flex flex-col gap-2 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-xs font-mono border-b border-slate-800/80 pb-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"font-bold text-white tracking-wider"},"WAVE OBSERVER"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] bg-cyan-950 border border-cyan-800 text-cyan-400 px-1.5 py-0.5 rounded"},"Real-time Oscilloscope")),/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 truncate max-w-[200px]"},"Context: ",track.name)),/*#__PURE__*/React.createElement("div",{className:"relative w-full h-36 bg-slate-950 border border-slate-900 rounded-lg overflow-hidden"},/*#__PURE__*/React.createElement("canvas",{ref:scopeCanvasRef,className:"w-full h-full block cursor-crosshair"})),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between bg-slate-900/90 border border-slate-800 rounded-lg px-3 py-1.5 text-xs font-mono flex-wrap gap-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 pr-3 border-r border-slate-800"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-400"},"Input"),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1 w-12"},/*#__PURE__*/React.createElement("div",{className:"h-1.5 bg-slate-950 rounded overflow-hidden flex"},/*#__PURE__*/React.createElement("div",{ref:scopeMeterLRef,className:"h-full bg-cyan-400 w-0 transition-all"})),/*#__PURE__*/React.createElement("div",{className:"h-1.5 bg-slate-950 rounded overflow-hidden flex"},/*#__PURE__*/React.createElement("div",{ref:scopeMeterRRef,className:"h-full bg-cyan-400 w-0 transition-all"})))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-500 text-[10px]"},"Channel:"),/*#__PURE__*/React.createElement("select",{value:scopeChannel,onChange:e=>setScopeChannel(e.target.value),className:"bg-slate-950 border border-slate-800 text-slate-300 rounded px-1.5 py-0.5 text-[11px] outline-none"},/*#__PURE__*/React.createElement("option",{value:"stereo"},"Stereo"),/*#__PURE__*/React.createElement("option",{value:"left"},"Left"),/*#__PURE__*/React.createElement("option",{value:"right"},"Right"),/*#__PURE__*/React.createElement("option",{value:"mid"},"Mid"),/*#__PURE__*/React.createElement("option",{value:"side"},"Side"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-500 text-[10px]"},"Mode:"),/*#__PURE__*/React.createElement("select",{value:scopeMode,onChange:e=>setScopeMode(e.target.value),className:"bg-slate-950 border border-slate-800 text-slate-300 rounded px-1.5 py-0.5 text-[11px] outline-none"},/*#__PURE__*/React.createElement("option",{value:"waveform"},"Waveform"),/*#__PURE__*/React.createElement("option",{value:"lissajous"},"Lissajous"),/*#__PURE__*/React.createElement("option",{value:"spectrum"},"Spectrum"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-500 text-[10px]"},"Duration:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold text-[11px] w-12"},scopeDuration.toFixed(2),"s"),/*#__PURE__*/React.createElement("input",{type:"range",min:"0.1",max:"5.0",step:"0.1",value:scopeDuration,onChange:e=>setScopeDuration(parseFloat(e.target.value)),className:"w-16 h-1 cursor-pointer",style:{accentColor:'#38bdf8'}})),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-500 text-[10px]"},"V.Zoom:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold text-[11px] w-12"},scopeZoom>0?'+':'',scopeZoom.toFixed(1)," dB"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"20",step:"0.5",value:scopeZoom,onChange:e=>setScopeZoom(parseFloat(e.target.value)),className:"w-16 h-1 cursor-pointer",style:{accentColor:'#38bdf8'}})),/*#__PURE__*/React.createElement("button",{onClick:()=>setScopePaused(p=>!p),className:`px-3 py-1 font-semibold rounded text-[11px] border transition-colors ${scopePaused?'bg-amber-600 border-amber-400 text-white':'bg-slate-800 hover:bg-slate-700 text-slate-300 border-slate-700'}`},scopePaused?'Resume':'Pause'))))),addModuleOpen&&/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[120] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4",onClick:()=>setAddModuleOpen(false)},/*#__PURE__*/React.createElement("div",{className:"w-full max-w-lg bg-slate-900 border border-slate-700 rounded-2xl p-5 space-y-4 shadow-2xl max-h-[80vh] overflow-y-auto",onClick:e=>e.stopPropagation()},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-800 pb-3"},/*#__PURE__*/React.createElement("h3",{className:"text-xs font-bold text-white uppercase tracking-wider font-mono"},"THÊM MODULE VÀO FX CHAIN"),/*#__PURE__*/React.createElement("button",{onClick:()=>setAddModuleOpen(false),className:"text-slate-400 hover:text-white"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-3 text-xs"},Object.keys(TRACK_FX_META).map(t=>{const meta=TRACK_FX_META[t];return/*#__PURE__*/React.createElement("button",{key:t,onClick:()=>addMod(t),className:"p-3 bg-slate-950 hover:bg-slate-800 border border-slate-800 rounded-xl text-left space-y-1 transition-colors"},/*#__PURE__*/React.createElement("div",{className:"font-bold flex items-center gap-1.5",style:{color:meta.color}},/*#__PURE__*/React.createElement("i",{"data-lucide":meta.icon,className:"w-3.5 h-3.5"})," ",meta.name),/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-slate-400"},meta.sub));})))),/*#__PURE__*/React.createElement(VstPluginPicker,{open:vstPickerOpen,onClose:()=>setVstPickerOpen(false),plugins:fxList,onPick:f=>addVst(f.path)}));};// ──────────────────────────────────────────────
// MASTERING MODAL COMPONENT (from md/45_MASTERING_MODULE.md)
// ──────────────────────────────────────────────
// GUI embed state survives MasteringModal unmount so the native window
@@ -1186,7 +1214,9 @@ const handlePrefsChange=next=>{setPrefs(next);try{localStorage.setItem('sf_prefs
const[fxRackTarget,setFxRackTarget]=useState(null);window.__openFxRack=(trackId,trackName)=>setFxRackTarget({trackId,trackName});// Render offline toàn project qua native_bridge (VST FX masterbus + track) → WAV.
// Dùng cho nút "Nghe thử" trong FX Rack / Mastering — preview = âm export.
window.__renderProjectPreview=async()=>{const schema=serializeProjectToSchema(currentProjectId||'temp_project',projectName||'Dự án tạm chưa lưu',bpm,tracks,subTabs,sessionTabs,masteringSettings,masterVolume);try{const res=await window.SonicAPI.renderProject(schema,'preview_'+Date.now()+'.wav');return res;}catch(e){window.showToast&&window.showToast('Render preview lỗi: '+(e.message||e),'error');throw e;}};const[masteringSettings,setMasteringSettings]=useState({masterConnected:true,// ⚠️ FIX: bật ON mặc định — MIDI item/preview PHẢI được xử lí qua mastering FX ngay khi chạy app (không cần bật/tắt power)
-activeModule:'eq',eqActive:true,imagerActive:true,maximizerActive:true,eqLowGain:1.5,eqMid1Gain:-1.0,eqMid2Gain:2.0,eqHighGain:1.8,w1:0,w2:115,w3:135,w4:150,imagerScale:'v2',maxGain:5.4,maxUpward:2.0,maxSoftClip:15,maxTransient:25,ceiling:-0.1,isBypassed:false,chain:DEFAULT_MASTER_CHAIN.map(m=>({...m})),compActive:false,compThreshold:-16,compRatio:3,compMakeup:0,limActive:false,limThreshold:-1.0,excActive:false,excDrive:40,rebalActive:false,rebalMid:0,rebalSide:0,vstFxChain:[],vstBypass:false});useEffect(()=>{window.currentMasteringSettings=masteringSettings;if(audioCtx&&masterBus){toggleMasteringOnMaster(masteringSettings.masterConnected,masteringSettings.isBypassed);applyMasteringSettings(masteringSettings);// Re-sync live track routes: mastering ON → mọi track qua chain (♪ bị
+activeModule:'eq',eqActive:true,imagerActive:true,maximizerActive:true,eqLowGain:1.5,eqMid1Gain:-1.0,eqMid2Gain:2.0,eqHighGain:1.8,w1:0,w2:115,w3:135,w4:150,imagerScale:'v2',maxGain:5.4,maxUpward:2.0,maxSoftClip:15,maxTransient:25,ceiling:-0.1,isBypassed:false,chain:DEFAULT_MASTER_CHAIN.map(m=>({...m})),compActive:false,compThreshold:-16,compRatio:3,compMakeup:0,limActive:false,limThreshold:-1.0,excActive:false,excDrive:40,rebalActive:false,rebalMid:0,rebalSide:0,vstFxChain:[],vstBypass:false});useEffect(()=>{window.currentMasteringSettings=masteringSettings;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).
try{const _list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;_list.forEach(_t=>{const _n=activeTrackNodesRef.current[_t.id];if(_n){if(_n.sfRouteGain&&_n.sfDryGain){const _b=effMidiBypass(_t);_n.sfRouteGain.gain.value=_b?0:1;_n.sfDryGain.gain.value=_b?1:0;}if(_n.route&&_n.route.routeGain&&_n.route.dryGain){const _ab=routeBypassFor(_t);_n.route.routeGain.gain.value=_ab?0:1;_n.route.dryGain.gain.value=_ab?1:0;}}});updateSfRouting();}catch(e){}}},[masteringSettings]);const[pluginManagerModalOpen,setPluginManagerModalOpen]=useState(false);const[pluginsData,setPluginsData]=useState(null);// D10: bridge indicator + "Ép dùng WASM" debug toggle.
@@ -1587,8 +1617,16 @@ const trackList=activeTracksRef.current&&activeTracksRef.current.length?activeTr
// 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 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);if(!track)return;node.fxEntry.disconnect();const fxEnabled=track.fxActive!==false;const chainMods=fxEnabled?(track.fxChain||[]).filter(m=>m&&m.active!==false).map(m=>{try{return createTrackFxModule(m.type||m,getAudioContext(),m.params);}catch(e){return null;}}).filter(Boolean):[];let tail=node.fxEntry;chainMods.forEach(mod=>{tail.connect(mod.input);tail=mod.output;});tail.connect(node.fxLegacyIn);node.fxMods=chainMods;// Rebuild the independent SF chain too (same modules, separate instances)
-if(node.sfEntry&&node.sfOut){node.sfEntry.disconnect();const sfMods=fxEnabled?(track.fxChain||[]).filter(m=>m&&m.active!==false).map(m=>{try{return createTrackFxModule(m.type||m,getAudioContext(),m.params);}catch(e){return null;}}).filter(Boolean):[];node.sfMods=sfMods;let sfTail=node.sfEntry;sfMods.forEach(mod=>{sfTail.connect(mod.input);sfTail=mod.output;});sfTail.connect(node.sfOut);}if(node.sfPan&&getAudioContext()){node.sfPan.pan.setTargetAtTime((track.pan??0)/100,getAudioContext().currentTime,0.02);}}catch(e){console.warn('rebuildTrackFxGraph error:',e);}};const rebuildTrackFxGraphRef=useRef(null);rebuildTrackFxGraphRef.current=rebuildTrackFxGraph;window.__rebuildTrackFxGraph=rebuildTrackFxGraph;// Route the SHARED FluidSynth output through the gainNode of the single
+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;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=>{try{return createTrackFxModule(m.type||m,getAudioContext(),m.params);}catch(e){return null;}}).filter(Boolean):[];let tail=node.fxEntry;chainMods.forEach(mod=>{tail.connect(mod.input);tail=mod.output;});tail.connect(node.fxLegacyIn);node.fxMods=chainMods;// Rebuild the independent SF chain too (same modules, separate instances)
+if(node.sfEntry&&node.sfOut){node.sfEntry.disconnect();const sfMods=fxEnabled?(track.fxChain||[]).filter(m=>m&&m.active!==false).map(m=>{try{return createTrackFxModule(m.type||m,getAudioContext(),m.params);}catch(e){return null;}}).filter(Boolean):[];node.sfMods=sfMods;let sfTail=node.sfEntry;sfMods.forEach(mod=>{sfTail.connect(mod.input);sfTail=mod.output;});sfTail.connect(node.sfOut);}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);}};const rebuildTrackFxGraphRef=useRef(null);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;ifxRtTrackStop(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
// audible MIDI track the shared worklet cannot split per channel, so we fall
@@ -1697,7 +1735,8 @@ const epoch=bridgeNoteEpochRef.current;setTimeout(function(){if(epoch!==bridgeNo
// FluidSynth GM sai âm — safety net cho mọi caller (ghost notes, per-note).
if(isStandaloneSf()&&isVstTrackEngine(synthEngine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive())){playNativeVstNote(track,pitch,velocity,durMs,startTime,track?track.id:'global');return;}window.SonicSF.playNote(pitch,velocity,durMs,startTime,program,destNode,ch,synthEngine);};const startTrackPlayback=offsetTime=>{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
-// D2: bridge active -> báo transport PLAY (bridge flush note-off cũ + đồng
+// 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){// V10: loop restart — stopAllPlayback vừa schedule STOP#2 (150ms); hủy
// ngay khi PLAY, nếu không STOP#2 đến sau PLAY → bridge drop mọi note-on
@@ -1710,7 +1749,10 @@ isPlayingRef.current=true;// ⚠️ FIX: đồng bộ mastering + Carla status N
// qua mastering FX (khi bật) và qua Carla bridge (khi VSTi loaded).
try{if(window.__ensureMasteringRouting)window.__ensureMasteringRouting();}catch(e){}refreshCarlaStatus();const allPlayTracks=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:activeTracks;// Capture items signature at schedule time — updatePlayhead so sánh với
// signature này để phát hiện items đổi vị trí giữa lúc play (re-schedule).
-scheduledItemsSigRef.current=buildItemsSignature(allPlayTracks,sessionTabs);console.log('[Play] offset='+offsetTime+' tracks='+allPlayTracks.length+' masterBus='+!!masterBus+' dest='+(context.destination?'ok':'MISSING')+' projectEnd='+(projectEndRef.current||0).toFixed(2));const hasSolo=allPlayTracks.some(t=>t.solo);allPlayTracks.forEach(track=>{const isPlayable=hasSolo?track.solo:!track.muted;if(!isPlayable)return;const gainNode=getOrCreateTrackNode(track,context);const pannerNode=activeTrackNodesRef.current[track.id].pannerNode;// Re-apply route gains theo content-type NGAY tại thời điểm play — node
+scheduledItemsSigRef.current=buildItemsSignature(allPlayTracks,sessionTabs);console.log('[Play] offset='+offsetTime+' tracks='+allPlayTracks.length+' masterBus='+!!masterBus+' dest='+(context.destination?'ok':'MISSING')+' projectEnd='+(projectEndRef.current||0).toFixed(2));const hasSolo=allPlayTracks.some(t=>t.solo);allPlayTracks.forEach(track=>{const isPlayable=hasSolo?track.solo:!track.muted;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ũ)
// → first play sau double-click vào SECTION-TAB bị bypass. Re-apply
// routeBypassFor(track) mỗi lần schedule sẽ hết chu kỳ "stop+play mới đúng".
@@ -1803,7 +1845,9 @@ setSubTabs(prev=>prev.map(s=>s.isPlaying?{...s,isPlaying:false}:s));setIsPlaying
setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,isPlaying:false}:s));}else{try{stopAllPlayback();const startOffset=st.currentTime||0;if(st.type==='PIANO_ROLL'){schedulePianoRollMidi(st,startOffset);startSubTabPlayback(st,startOffset);}else{startSubTabPlayback(st,startOffset);}setSubTabs(prev=>prev.map(s=>s.id===activeTab?{...s,buffer:s.buffer||getAudioContext().createBuffer(1,128,getAudioContext().sampleRate),isPlaying:true,currentTime:startOffset}:s));// BẮT BUỘC start rAF loop updatePlayhead — nếu không, playhead sub-tab
// không di chuyển (và loop/stop không bao giờ chạy).
animationFrameIdRef.current=requestAnimationFrame(updatePlayhead);}catch(e){console.error('[Play] Piano Roll play error:',e);showToast('Lỗi phát Piano Roll: '+e.message,'error');}}return;}const context=getAudioContext();if(isPlaying){stopAllPlayback(true);// user stop — fade out
-}else{startOffsetTimeRef.current=currentTime;startAudioTimeRef.current=context.currentTime;startTrackPlayback(currentTime);setIsPlaying(true);}};handlePlayPauseRef.current=handlePlayPause;const handlePianoRollRealtimePlay=trackIds=>{const prSt=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL');if(!prSt)return;var prLayers=[];var ghostData=window.SonicGhost&&window.SonicGhost.extractGhostLayers?window.SonicGhost.extractGhostLayers(activeTracks,prSt.trackId,prSt.target_id,parseInt(bpm)||120):[];(ghostData||[]).forEach(function(layer){if(!trackIds||trackIds.indexOf(layer.track_id)===-1)return;var ltrk=(activeTracks||[]).find(function(t){return t.id===layer.track_id;});prLayers.push({trackId:layer.track_id,notes:layer.notes.map(function(n){return{pitch:n.pitch,start_beat:n.relative_start_beat,duration_beats:n.duration_beats,velocity:n.velocity||0.8};}),instrumentProgram:ltrk?ltrk.instrumentProgram:undefined,instrumentName:ltrk?ltrk.instrumentName:undefined,synthEngine:ltrk?ltrk.synth_engine:undefined});});stopAllPlayback();const prCtx=getAudioContext();const prTNode=activeTrackNodesRef.current[prSt.trackId];if(prTNode&&prTNode.gainNode){prTNode.gainNode.gain.setValueAtTime(prTNode.gainNode.gain.value||1,prCtx.currentTime);prTNode.gainNode.gain.linearRampToValueAtTime(0.001,prCtx.currentTime+0.04);}setTimeout(()=>{window.SonicSF.stopAll();stopAllNativeSfNotes();stopAllNativeVstNotes();if(prTNode&&prTNode.gainNode){const prTrackData=activeTracksRef.current?activeTracksRef.current.find(t=>t.id===prSt.trackId):null;const prVolDb=prTrackData?prTrackData.volumeDb??0:0;const prVolLinear=prVolDb<=-50?0:Math.pow(10,prVolDb/20);prTNode.gainNode.gain.setValueAtTime(0.001,prCtx.currentTime);prTNode.gainNode.gain.linearRampToValueAtTime(prVolLinear||0.8,prCtx.currentTime+0.015);}const prOffset=prSt.currentTime||0;startOffsetTimeRef.current=prOffset;startAudioTimeRef.current=prCtx.currentTime;startBufferOffsetRef.current=prOffset*(prSt.speed||1.0);const playTab=Object.assign({},prSt,{ghostPlayLayers:prLayers});schedulePianoRollMidi(playTab,prOffset);setSubTabs(prev=>prev.map(s=>s.id===prSt.id?Object.assign({},s,{ghostPlayLayers:prLayers,isPlaying:true,currentTime:prOffset}):s));},60);};const handlePause=()=>{if(isPlaying||subTabs.some(s=>s.isPlaying))stopAllPlayback(true);};const stopAllPlayback=fade=>{try{isPlayingRef.current=false;_nativePlayEpoch++;// hủy mọi render/decode pending của lần play cũ
+}else{startOffsetTimeRef.current=currentTime;startAudioTimeRef.current=context.currentTime;startTrackPlayback(currentTime);setIsPlaying(true);}};handlePlayPauseRef.current=handlePlayPause;const handlePianoRollRealtimePlay=trackIds=>{const prSt=subTabs.find(s=>s.id===activeTab&&s.type==='PIANO_ROLL');if(!prSt)return;var prLayers=[];var ghostData=window.SonicGhost&&window.SonicGhost.extractGhostLayers?window.SonicGhost.extractGhostLayers(activeTracks,prSt.trackId,prSt.target_id,parseInt(bpm)||120):[];(ghostData||[]).forEach(function(layer){if(!trackIds||trackIds.indexOf(layer.track_id)===-1)return;var ltrk=(activeTracks||[]).find(function(t){return t.id===layer.track_id;});prLayers.push({trackId:layer.track_id,notes:layer.notes.map(function(n){return{pitch:n.pitch,start_beat:n.relative_start_beat,duration_beats:n.duration_beats,velocity:n.velocity||0.8};}),instrumentProgram:ltrk?ltrk.instrumentProgram:undefined,instrumentName:ltrk?ltrk.instrumentName:undefined,synthEngine:ltrk?ltrk.synth_engine:undefined});});stopAllPlayback();const prCtx=getAudioContext();const prTNode=activeTrackNodesRef.current[prSt.trackId];if(prTNode&&prTNode.gainNode){prTNode.gainNode.gain.setValueAtTime(prTNode.gainNode.gain.value||1,prCtx.currentTime);prTNode.gainNode.gain.linearRampToValueAtTime(0.001,prCtx.currentTime+0.04);}setTimeout(()=>{window.SonicSF.stopAll();stopAllNativeSfNotes();stopAllNativeVstNotes();if(prTNode&&prTNode.gainNode){const prTrackData=activeTracksRef.current?activeTracksRef.current.find(t=>t.id===prSt.trackId):null;const prVolDb=prTrackData?prTrackData.volumeDb??0:0;const prVolLinear=prVolDb<=-50?0:Math.pow(10,prVolDb/20);prTNode.gainNode.gain.setValueAtTime(0.001,prCtx.currentTime);prTNode.gainNode.gain.linearRampToValueAtTime(prVolLinear||0.8,prCtx.currentTime+0.015);}const prOffset=prSt.currentTime||0;startOffsetTimeRef.current=prOffset;startAudioTimeRef.current=prCtx.currentTime;startBufferOffsetRef.current=prOffset*(prSt.speed||1.0);const playTab=Object.assign({},prSt,{ghostPlayLayers:prLayers});schedulePianoRollMidi(playTab,prOffset);setSubTabs(prev=>prev.map(s=>s.id===prSt.id?Object.assign({},s,{ghostPlayLayers:prLayers,isPlaying:true,currentTime:prOffset}):s));},60);};const handlePause=()=>{if(isPlaying||subTabs.some(s=>s.isPlaying))stopAllPlayback(true);};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).
bridgeNoteEpochRef.current++;try{clearAllPendingNoteOffs();}catch(e){}try{if(subTabsRef.current)subTabsRef.current=subTabsRef.current.map(s=>({...s,isPlaying:false}));}catch(e){}// Fade-out khi user nhấn Stop/Pause (fade=true): ramp gain track + bridge
@@ -1938,7 +1982,11 @@ const triggerWavExport=async()=>{// TỰ ĐỘNG bounce realtime nếu project c
const allT=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;const hasMidiItems=allT.some(t=>t.midiItems&&t.midiItems.length>0)||(sessionTabs||[]).some(s=>(s.tracks||[]).some(t=>t.midiItems&&t.midiItems.length>0));if(hasMidiItems){// Nếu preview đã cache đủ độ dài cho MỌI track MIDI → export OFFLINE NHANH
// bằng cache (vẫn qua FX Rack + Mastering như playback). Ngược lại →
// bounce realtime để đảm bảo trung thực.
-const midiTracks=allT.filter(t=>t.midiItems&&t.midiItems.length>0);let needDur=1.0;midiTracks.forEach(t=>{(t.midiItems||[]).forEach(m=>{if(m.endTime)needDur=Math.max(needDur,m.endTime);else if(m.startTime)needDur=Math.max(needDur,m.startTime+(m.duration||8));});});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;});if(cacheOK){showToast("Dùng MIDI cache từ preview — export OFFLINE NHANH (vẫn đủ FX Rack + Mastering Chain).","info");clientSideExport(allT);return;}showToast("Chưa có MIDI cache đầy đủ — bounce realtime để đảm bảo trung thực.","info");triggerBounceExport();return;}let exportTracks;let clipStart=0;let clipEnd=0;const src=exportSettings.source;if(src==='active_clip'||src==='clip_selection'){const selTrack=selectedTrackId?tracks.find(t=>t.id===selectedTrackId):null;if(!selTrack||!selTrack.buffer){showToast("Không có clip nào được chọn.","warning");return;}let rangeStart=selectionStart;let rangeEnd=selectionEnd;if(rangeStart===null||rangeEnd===null||rangeEnd<=rangeStart){rangeStart=0;rangeEnd=selTrack.buffer.duration;}const ctx=getAudioContext();const numCh=selTrack.buffer.numberOfChannels||1;const sr=selTrack.buffer.sampleRate;const startSample=Math.max(0,Math.floor(rangeStart*sr));const endSample=Math.min(selTrack.buffer.length,Math.floor(rangeEnd*sr));const len=endSample-startSample;if(len<=100){showToast("Vùng chọn quá ngắn hoặc không có dữ liệu.","warning");return;}const clipBuf=ctx.createBuffer(numCh,len,sr);for(let ch=0;cht.buffer&&!t.muted);const selTrk=selectedTrackId?sel.filter(t=>t.id===selectedTrackId):sel;if(selTrk.length===0){showToast("Track được chọn không có dữ liệu.","warning");return;}exportTracks=selTrk;}else{exportTracks=tracks.filter(t=>t.buffer&&!t.muted);}if(exportTracks.length===0){showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.","warning");return;}// LOOP export (BIG_PHASE_3 Phase 2): cắt chính xác vùng loop — bắt đầu từ
+const midiTracks=allT.filter(t=>t.midiItems&&t.midiItems.length>0);let needDur=1.0;midiTracks.forEach(t=>{(t.midiItems||[]).forEach(m=>{if(m.endTime)needDur=Math.max(needDur,m.endTime);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;i0.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&&cacheHasEnergy(c);});if(cacheOK){showToast("Dùng MIDI cache từ preview — export OFFLINE NHANH (vẫn đủ FX Rack + Mastering Chain).","info");clientSideExport(allT);return;}showToast("Chưa có MIDI cache đầy đủ — bounce realtime để đảm bảo trung thực.","info");triggerBounceExport();return;}let exportTracks;let clipStart=0;let clipEnd=0;const src=exportSettings.source;if(src==='active_clip'||src==='clip_selection'){const selTrack=selectedTrackId?tracks.find(t=>t.id===selectedTrackId):null;if(!selTrack||!selTrack.buffer){showToast("Không có clip nào được chọn.","warning");return;}let rangeStart=selectionStart;let rangeEnd=selectionEnd;if(rangeStart===null||rangeEnd===null||rangeEnd<=rangeStart){rangeStart=0;rangeEnd=selTrack.buffer.duration;}const ctx=getAudioContext();const numCh=selTrack.buffer.numberOfChannels||1;const sr=selTrack.buffer.sampleRate;const startSample=Math.max(0,Math.floor(rangeStart*sr));const endSample=Math.min(selTrack.buffer.length,Math.floor(rangeEnd*sr));const len=endSample-startSample;if(len<=100){showToast("Vùng chọn quá ngắn hoặc không có dữ liệu.","warning");return;}const clipBuf=ctx.createBuffer(numCh,len,sr);for(let ch=0;cht.buffer&&!t.muted);const selTrk=selectedTrackId?sel.filter(t=>t.id===selectedTrackId):sel;if(selTrk.length===0){showToast("Track được chọn không có dữ liệu.","warning");return;}exportTracks=selTrk;}else{exportTracks=tracks.filter(t=>t.buffer&&!t.muted);}if(exportTracks.length===0){showToast("Không tìm thấy dữ liệu âm thanh hợp lệ để xuất.","warning");return;}// LOOP export (BIG_PHASE_3 Phase 2): cắt chính xác vùng loop — bắt đầu từ
// selection (nếu có) hoặc playhead, độ dài = loopBars * secondsPerBar.
// Không fade in/out, không kéo đuôi (cắt cứng tại biên).
let loopStart=0,loopDur=0;if(exportSettings.loop){const loopSecPerBar=60.0/(parseFloat(bpm)||120)*4;loopStart=selectionStart!==null&&selectionEnd!==null&&selectionEnd>selectionStart?selectionStart:currentTime;loopDur=Math.max(0.1,(exportSettings.loopBars||4)*loopSecPerBar);const lctx=getAudioContext();exportTracks=exportTracks.map(t=>{if(!t.buffer)return null;const ls=Math.max(0,Math.floor(loopStart*lctx.sampleRate));const le=Math.min(t.buffer.length,Math.ceil((loopStart+loopDur)*lctx.sampleRate));if(le<=ls)return null;const buf=lctx.createBuffer(t.buffer.numberOfChannels,le-ls,t.buffer.sampleRate);for(let ch=0;ch{const c=midiCacheRef.current[id];if(c&&!loopActive&&c.duration>durationLimit)durationLimit=c.duration;});const outChannels=exportSettings.channels==='mono'?1:2;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
+const loopActive=!!(loopInfo&&loopInfo.loopDur);Object.keys(midiCacheRef.current).forEach(id=>{const c=midiCacheRef.current[id];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{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.
const savedMasterBus=masterBus;const offlineNodes={};try{window.currentMasteringSettings=masteringSettings||window.currentMasteringSettings;try{if(typeof stopAllPlayback==='function')stopAllPlayback();}catch(e){}masterBus=null;initMasterBus(offlineCtx);try{toggleMasteringOnMaster(!!(masteringSettings&&masteringSettings.masterConnected),!!(masteringSettings&&masteringSettings.isBypassed));}catch(e){console.warn('offline mastering toggle error:',e);}// Main out volume (master fader) áp vào offline bus — file export phải
// đúng độ lớn nghe được (flow: ... → Mastering FX Chain → Main out).
// Trước đây offline masterBus.output luôn = 1.0 → export sai volume.
-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:',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{for(let i=0;i>8&0xFF);view.setUint8(offset+2,val24>>16&0xFF);}else if(isFloatWav){view.setFloat32(offset,sample,true);}offset+=bytesPerSample;}}const blob=new Blob([view],{type:'audio/wav'});const wavName=`sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`;if((exportSettings.format==='mp3'||exportSettings.format==='ogg')&&serverStatus==='connected'){setIsExporting(true);showToast("Đang gửi yêu cầu nén định dạng lên máy chủ...","info");try{const file=new File([blob],`session_export.wav`,{type:'audio/wav'});const formData=new FormData();formData.append('file',file);const uploadResp=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:formData});if(!uploadResp.ok)throw new Error("Upload to transcode server failed");const uploadData=await uploadResp.json();const fileId=uploadData.file_id;const exportResp=await fetch(`${API_AUDIO}/export`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:fileId,format:exportSettings.format,sample_rate:targetRate,bit_depth:bitDepth})});if(!exportResp.ok)throw new Error("Transcode request failed");const exportData=await exportResp.json();const result=await pollTaskResult(exportData.task_id,20);if(result.success){const outId=result.output_file_id;const downloadUrl=`${API_AUDIO}/download/${outId}`;showToast(`Xuất bản âm thanh định dạng ${exportSettings.format.toUpperCase()} thành công!`,"success","Tải về",async()=>{try{const br=await fetch(downloadUrl);if(!br.ok)throw new Error('HTTP '+br.status);const b=await br.blob();const sp=await saveExportViaDialog(`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`,b);if(sp)showToast(`Đã lưu → ${sp}`,"success");}catch(e){const a=document.createElement('a');a.href=downloadUrl;a.download=`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`;a.click();}});}else{throw new Error(result.error||'Server encoding failed');}}catch(transcodeErr){showToast(`Lỗi chuyển đổi: ${transcodeErr.message}.`,"warning","Tải WAV thay thế",()=>{downloadBlobViaServer(blob,wavName).catch(()=>{});});}}else{if(exportSettings.format!=='wav'){showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.","warning");}const dres=await downloadBlobViaServer(blob,wavName).catch(()=>null);if(dres&&dres.saved_path){showToast(`Xuất bản âm thanh hoàn tất → ${dres.saved_path}`,"success");}else{showToast("Xuất bản âm thanh hoàn tất!","success");}}}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){}Object.keys(offlineNodes).forEach(k=>{try{if(offlineNodes[k].fxStopFn)offlineNodes[k].fxStopFn();}catch(e){}});}}catch(err){showToast("Lỗi xuất âm thanh: "+err.message,"error");}finally{setIsExporting(false);}};// ── AI Analysis (server-side with fallback) ──
+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:',e);}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;}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{scheduleTrackInto(t,offlineCtx,offlineNodes,false);});renderedBuffer=await offlineCtx.startRendering();}const numExportCh=renderedBuffer.numberOfChannels;const exportLength=renderedBuffer.length;const bytesPerSample=bitDepth/8;const headerSize=44;const fileSizeBytes=headerSize+exportLength*bytesPerSample*numExportCh;const fileBuffer=new ArrayBuffer(fileSizeBytes);const view=new DataView(fileBuffer);const writeString=(offset,string)=>{for(let i=0;i>8&0xFF);view.setUint8(offset+2,val24>>16&0xFF);}else if(isFloatWav){view.setFloat32(offset,sample,true);}offset+=bytesPerSample;}}const blob=new Blob([view],{type:'audio/wav'});const wavName=`sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`;if((exportSettings.format==='mp3'||exportSettings.format==='ogg')&&serverStatus==='connected'){setIsExporting(true);showToast("Đang gửi yêu cầu nén định dạng lên máy chủ...","info");try{const file=new File([blob],`session_export.wav`,{type:'audio/wav'});const formData=new FormData();formData.append('file',file);const uploadResp=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:formData});if(!uploadResp.ok)throw new Error("Upload to transcode server failed");const uploadData=await uploadResp.json();const fileId=uploadData.file_id;const exportResp=await fetch(`${API_AUDIO}/export`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:fileId,format:exportSettings.format,sample_rate:targetRate,bit_depth:bitDepth})});if(!exportResp.ok)throw new Error("Transcode request failed");const exportData=await exportResp.json();const result=await pollTaskResult(exportData.task_id,20);if(result.success){const outId=result.output_file_id;const downloadUrl=`${API_AUDIO}/download/${outId}`;showToast(`Xuất bản âm thanh định dạng ${exportSettings.format.toUpperCase()} thành công!`,"success","Tải về",async()=>{try{const br=await fetch(downloadUrl);if(!br.ok)throw new Error('HTTP '+br.status);const b=await br.blob();const sp=await saveExportViaDialog(`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`,b);if(sp)showToast(`Đã lưu → ${sp}`,"success");}catch(e){const a=document.createElement('a');a.href=downloadUrl;a.download=`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`;a.click();}});}else{throw new Error(result.error||'Server encoding failed');}}catch(transcodeErr){showToast(`Lỗi chuyển đổi: ${transcodeErr.message}.`,"warning","Tải WAV thay thế",()=>{downloadBlobViaServer(blob,wavName).catch(()=>{});});}}else{if(exportSettings.format!=='wav'){showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.","warning");}const dres=await downloadBlobViaServer(blob,wavName).catch(()=>null);if(dres&&dres.saved_path){showToast(`Xuất bản âm thanh hoàn tất → ${dres.saved_path}`,"success");}else{showToast("Xuất bản âm thanh hoàn tất!","success");}}}finally{// 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){showToast("Lỗi xuất âm thanh: "+err.message,"error");}finally{setIsExporting(false);}};// ── AI Analysis (server-side with fallback) ──
const triggerAIAnalysis=async()=>{setAnalysisState({status:'Connecting to AI Engine...',data:null,isRunning:true});// Check if we have a file on the server to analyze
const activeTrack=tracks.find(t=>t.id===selectedTrackId);const serverFileId=serverFileIdMap[selectedTrackId];if(serverFileId&&serverStatus==='connected'){try{const resp=await fetch(`${API_AUDIO}/analyze-ai`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:serverFileId,api_base_url:aiConfig.baseUrl!==API_BASE_URL?aiConfig.baseUrl:null,model:aiConfig.model})});if(resp.ok){const data=await resp.json();showToast("Đang phân tích cấu trúc trên máy chủ...","info");try{const result=await pollTaskResult(data.task_id,15);if(result.bpm){if(selLeft!==null&&selRight!==null&&activeTrack&&activeTrack.buffer){const snapStart=findZeroCrossing(activeTrack.buffer,selLeft);const snapEnd=findZeroCrossing(activeTrack.buffer,selRight);if(selectionMode==='local'){setLocalSelectionStart(snapStart);setLocalSelectionEnd(snapEnd);}else{setSelectionStart(snapStart);setSelectionEnd(snapEnd);}}setAnalysisState({status:'Hoàn thành phân tích (Server)',data:result,isRunning:false});showToast(`AI Server: ${result.bpm} BPM | ${result.beats?.length||0} beats detected (Zero-Crossing Aligned)`,'success');return;}}catch(e){console.warn('Server AI poll failed, using fallback:',e);}}}catch(err){console.warn('Server AI analysis failed, using fallback:',err);}}// Fallback: client-side analysis with simulated BPM
setAnalysisState({status:'Processing structural detection...',data:null,isRunning:true});setTimeout(()=>{let bpm=120;// Try to detect BPM from buffer
diff --git a/app/static/js/services/api.js b/app/static/js/services/api.js
index ef3b85e..025ae1b 100644
--- a/app/static/js/services/api.js
+++ b/app/static/js/services/api.js
@@ -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) }),
diff --git a/app/static/js/sf-fx-realtime.js b/app/static/js/sf-fx-realtime.js
new file mode 100644
index 0000000..8945297
--- /dev/null
+++ b/app/static/js/sf-fx-realtime.js
@@ -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);
diff --git a/app/templates/index.html b/app/templates/index.html
index d34d3d4..8d7a524 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -51,7 +51,7 @@
-
+