From bb6658e03d6a7c94474233e876ec7bb0ca977e8c Mon Sep 17 00:00:00 2001 From: locphamtran Date: Fri, 21 Aug 2026 21:48:56 +0700 Subject: [PATCH] =?UTF-8?q?fix(fx):=20realtime=20VST=20ng=E1=BA=AFt=20qu?= =?UTF-8?q?=C3=A3ng=20khi=20m=E1=BB=9F=20MASTERING=20PANEL=20+=20PWR=20mas?= =?UTF-8?q?ter=20kh=C3=B4ng=20=E1=BA=A3nh=20h=C6=B0=E1=BB=9Fng=20=C3=A2m;?= =?UTF-8?q?=20pacing=20WS=20input/output,=20gate=20fxRt=20theo=20masterCon?= =?UTF-8?q?nected,=20worklet=20cap=2032?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN_SHM_REALTIME.md | 109 +++++ app/api/v1/files.py | 22 +- app/api/v1/plugins.py | 115 ++++- app/core/fx_realtime.py | 241 +++++++++++ app/core/render_engine.py | 317 ++++++++++++-- app/static/js/app.jsx | 597 +++++++++++++++++++++++--- app/static/js/app.precompiled.js | 101 ++++- app/static/js/services/api.js | 4 + app/static/js/sf-fx-realtime.js | 75 ++++ app/templates/index.html | 2 +- engine.spec | 3 + fx_chain_architecture.md | 98 +++++ native_bridge/CMakeLists.txt | 5 +- native_bridge/include/FxRealtimeIPC.h | 39 ++ native_bridge/include/RenderFxJob.h | 5 + native_bridge/src/RealtimeFxLoop.cpp | 194 +++++++++ native_bridge/src/main_fx.cpp | 28 +- 17 files changed, 1813 insertions(+), 142 deletions(-) create mode 100644 PLAN_SHM_REALTIME.md create mode 100644 app/core/fx_realtime.py create mode 100644 app/static/js/sf-fx-realtime.js create mode 100644 fx_chain_architecture.md create mode 100644 native_bridge/include/FxRealtimeIPC.h create mode 100644 native_bridge/src/RealtimeFxLoop.cpp 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 @@ - +