From c3aba40ffee68282a5a1465703c4d8e1d1718cbf Mon Sep 17 00:00:00 2001 From: locphamtran Date: Sat, 22 Aug 2026 21:02:52 +0700 Subject: [PATCH] =?UTF-8?q?Phase=204+5:=20WebAudio=20I/O=20shim=20+=20veri?= =?UTF-8?q?fy=20DAW=20checklist=20=E2=80=94=20live=3Dexport,=20order,=20fa?= =?UTF-8?q?der,=20PDC,=20smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TASKS_DAW_A.md | 26 +- app/core/fx_realtime.py | 2 + app/core/render_engine.py | 6 +- app/static/js/app.jsx | 526 ++++++++++++--------------- app/static/js/app.precompiled.js | 108 ++---- app/static/js/sf-fx-realtime.js | 1 + native_bridge/src/BuiltinFxChain.cpp | 17 + native_bridge/src/RenderFxJob.cpp | 27 +- tests/test_daw_phase5.py | 431 ++++++++++++++++++++++ 9 files changed, 756 insertions(+), 388 deletions(-) create mode 100644 tests/test_daw_phase5.py diff --git a/TASKS_DAW_A.md b/TASKS_DAW_A.md index f5c0eb0..9942699 100644 --- a/TASKS_DAW_A.md +++ b/TASKS_DAW_A.md @@ -29,21 +29,21 @@ Trạng thái: `[x]` hoàn thành, `[ ]` chưa. Cập nhật sau mỗi phase. - [x] 3.4 Pytest: render offline có imager/maximizer master giống live. ## Phase 4 — Frontend: WebAudio chỉ I/O -- [ ] 4.1 Chain model 1 mảng duy nhất (builtin+vst3 theo UI order); migration project cũ vstFxChain. -- [ ] 4.2 Master: bỏ MASTER_MODULE_IO graph + rebuildMasteringGraph node-web; 1 worklet master; fader SAU worklet. -- [ ] 4.3 Track: bỏ fxMods/sfMods WebAudio builtin; 1 worklet track; gain/pan giữ. -- [ ] 4.4 `fxRtChain()`/`fxRtTrackChain()` đọc chain hợp nhất; chainKey so toàn chain. -- [ ] 4.5 applyMastering param → SET_PARAM push. -- [ ] 4.6 Worklet: forward SET_PARAM; queue drop → adaptive (ponytail). +- [x] 4.1 Chain model 1 mảng duy nhất (builtin+vst3 theo UI order); migration project cũ vstFxChain. +- [x] 4.2 Master: bỏ MASTER_MODULE_IO graph + rebuildMasteringGraph node-web; 1 worklet master; fader SAU worklet. +- [x] 4.3 Track: bỏ fxMods/sfMods WebAudio builtin; 1 worklet track; gain/pan giữ. +- [x] 4.4 `fxRtChain()`/`fxRtTrackChain()` đọc chain hợp nhất; chainKey so toàn chain. +- [x] 4.5 applyMastering param → SET_PARAM push. +- [x] 4.6 Worklet: forward SET_PARAM; queue drop → adaptive (ponytail). ## Phase 5 — Verify (chuẩn DAW checklist) -- [ ] 5.1 Build frontend `node build.mjs`. -- [ ] 5.2 Pytest toàn bộ. -- [ ] 5.3 Golden live=export (record masterBus vs offline WAV) diff ngưỡng. -- [ ] 5.4 Order test: [eq, vst3-delay, limiter] — impulse xác nhận delay sau EQ. -- [ ] 5.5 Fader test: chain active, fader -6dB → đỉnh giảm, chain input không đổi. -- [ ] 5.6 PDC test: plugin latency 1024 → xuyên pha hết. -- [ ] 5.7 Smoke server + render master imager+maximizer+vst3. +- [x] 5.1 Build frontend `node build.mjs`. +- [x] 5.2 Pytest toàn bộ. +- [x] 5.3 Golden live=export (record masterBus vs offline WAV) diff ngưỡng. +- [x] 5.4 Order test: [eq, vst3-delay, limiter] — impulse xác nhận delay sau EQ. +- [x] 5.5 Fader test: chain active, fader -6dB → đỉnh giảm, chain input không đổi. +- [x] 5.6 PDC test: plugin latency 1024 → xuyên pha hết. +- [x] 5.7 Smoke server + render master imager+maximizer+vst3. - [ ] 5.8 Commit + push standalone-shm-bridge. ## Review DAW-standard (sau Phase 5, bổ sung nếu thiếu) diff --git a/app/core/fx_realtime.py b/app/core/fx_realtime.py index a21e2e8..9efc983 100644 --- a/app/core/fx_realtime.py +++ b/app/core/fx_realtime.py @@ -140,6 +140,8 @@ class FxRealtimeSession: "type": s.get("type", ""), "bypass": bool(s.get("bypass")), } + if s.get("id"): + slot["id"] = s["id"] if s.get("path"): slot["path"] = s["path"] if s.get("preset_b64"): diff --git a/app/core/render_engine.py b/app/core/render_engine.py index 64a71d6..7cc1505 100644 --- a/app/core/render_engine.py +++ b/app/core/render_engine.py @@ -304,7 +304,11 @@ def _merge_track_fx_chain(track): if isinstance(slot, dict): merged.append(slot) if not track.get("vst_fx_bypass"): - merged += list(track.get("vst_fx_chain") or []) + seen = {s.get("path") for s in merged if s.get("path")} + for slot in (track.get("vst_fx_chain") or []): + if isinstance(slot, dict) and slot.get("path") in seen: + continue # đã có trong fx_chain (migration) — không nhân đôi + merged.append(slot) return merged # Param mapping master chain entry → C++ BuiltinFx (Phase 3.2). UI chain entry diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 7cba824..3e0df63 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -823,114 +823,9 @@ function applyMasteringSettings(s) { if (sig === _lastMasteringSig) return; _lastMasteringSig = sig; - const now = audioCtx.currentTime; - // Clamp every parameter so a stale/incomplete settings object can never push - // NaN or an extreme value into the biquad filters — that puts the master - // chain into a bad state and silences ALL audio (the "mất soundfont" symptom). - const clamp = (v, lo, hi) => { - const n = Number(v); - if (!isFinite(n)) return 0; // missing/NaN → neutral, never a filter-breaking value - return Math.min(hi, Math.max(lo, n)); - }; - - // 1. EQ Settings - // cancelScheduledValues + a slower time constant keeps rapid slider drags - // from piling up automation events on the biquad filters (the trigger for - // Chromium's "BiquadFilterNode: state is bad"). - // ⚠️ Guard NaN: setValueAtTime(NaN) trên biquad → Chromium "BiquadFilterNode: - // state is bad" + câm. Field settings undefined/NaN → mặc định 0. - const _g = (v, lo, hi) => (typeof v === 'number' && isFinite(v)) ? clamp(v, lo, hi) : 0; - masterBus.eqLowFilter.gain.cancelScheduledValues(now); - masterBus.eqLowFilter.gain.setValueAtTime(s.eqActive ? _g(s.eqLowGain, -24, 24) : 0, now); - masterBus.eqMid1Filter.gain.cancelScheduledValues(now); - masterBus.eqMid1Filter.gain.setValueAtTime(s.eqActive ? _g(s.eqMid1Gain, -24, 24) : 0, now); - masterBus.eqMid2Filter.gain.cancelScheduledValues(now); - masterBus.eqMid2Filter.gain.setValueAtTime(s.eqActive ? _g(s.eqMid2Gain, -24, 24) : 0, now); - masterBus.eqHighFilter.gain.cancelScheduledValues(now); - masterBus.eqHighFilter.gain.setValueAtTime(s.eqActive ? _g(s.eqHighGain, -24, 24) : 0, now); - - // 2. Imager Settings (Mid/Side width per band — imager_spec.md) - // Width % semantics per the guide: 0% = MONO (S × 0), 100% = original - // (S × 1), 200% = double width (S × 2). The L/R crossfeed gains below are - // exactly equivalent to M/S scaling: L'=g1·L+g2·R, R'=g1·R+g2·L with - // g1=(w+100)/200, g2=(100−w)/200 → Mid (L+R)/2 untouched, Side (L−R)/2 - // scaled by w/100. Bands: 1=20-100Hz, 2=100Hz-1kHz, 3=1k-6kHz, 4=6k-20kHz. - const updateImagerBand = (w, active, gainLL, gainRL, gainLR, gainRR) => { - const widthVal = (s.imagerActive && active) ? clamp(w, 0, 200) : 100; - const g1 = (widthVal + 100) / 200; - const g2 = (100 - widthVal) / 200; - - gainLL.gain.setTargetAtTime(g1, now, 0.01); - gainRR.gain.setTargetAtTime(g1, now, 0.01); - gainRL.gain.setTargetAtTime(g2, now, 0.01); - gainLR.gain.setTargetAtTime(g2, now, 0.01); - }; - - updateImagerBand(s.w1, true, masterBus.gainLL1, masterBus.gainRL1, masterBus.gainLR1, masterBus.gainRR1); - updateImagerBand(s.w2, true, masterBus.gainLL2, masterBus.gainRL2, masterBus.gainLR2, masterBus.gainRR2); - updateImagerBand(s.w3, true, masterBus.gainLL3, masterBus.gainRL3, masterBus.gainLR3, masterBus.gainRR3); - updateImagerBand(s.w4, true, masterBus.gainLL4, masterBus.gainRL4, masterBus.gainLR4, masterBus.gainRR4); - - // 3. Maximizer Settings - const boostLinear = (s.maximizerActive) ? Math.pow(10, clamp(s.maxGain, -60, 30) / 20) : 1.0; - masterBus.maximizerBoostGain.gain.setTargetAtTime(boostLinear, now, 0.01); - - // Soft Clipper (identity passthrough when off — never null curve). - // Unity-gain curve: bo gain an cua atan (slope ~2.1x cu cong +6 dB len - // maxGain -> tin hieu >~-12 dBFS bi hard-clip meo "be"). - if (s.maximizerActive && s.maxSoftClip > 0) { - masterBus.maximizerSoftClipper.curve = makeSoftClipCurve(s.maxSoftClip); - } else { - masterBus.maximizerSoftClipper.curve = new Float32Array([-1, 1]); - } - - // Upward Compressor - const upwardGainLinear = (s.maximizerActive && s.maxUpward > 0) ? (Math.pow(10, clamp(s.maxUpward, 0, 30) / 20) - 1.0) : 0.0; - masterBus.upwardGain.gain.setTargetAtTime(upwardGainLinear, now, 0.01); - - // Limiter Threshold (WaveShaper ceiling — _setCeiling rebuild curve) - const ceilingVal = s.maximizerActive ? clamp(s.ceiling, -60, 0) : -0.1; - if (masterBus.maximizerCompressor._setCeiling) masterBus.maximizerCompressor._setCeiling(ceilingVal); - - // 4. Bus Compressor module (mastering_expand.md §II.2) - if (masterBus.compNode) { - const compOn = !!s.compActive; - masterBus.compNode.threshold.setTargetAtTime(compOn ? clamp(s.compThreshold, -60, 0) : 0, now, 0.02); - masterBus.compNode.ratio.setTargetAtTime(compOn ? clamp(s.compRatio, 1, 20) : 1, now, 0.02); - masterBus.compMakeup.gain.setTargetAtTime(compOn ? Math.pow(10, clamp(s.compMakeup, 0, 12) / 20) : 1.0, now, 0.02); - } - - // 5. Brickwall Limiter module (WaveShaper tanh — threshold = mức clip; OFF = identity) - if (masterBus.limNode) { - const limOn = !!s.limActive; - if (limOn) { - if (masterBus.limNode._setThreshold) masterBus.limNode._setThreshold(clamp(s.limThreshold, -24, 0)); - } else { - try { masterBus.limNode.curve = new Float32Array([-1, 1]); } catch (e) {} - } - } - - // 6. Harmonic Exciter module (dry/wet mix; dry stays 1.0 for subtle warmth) - if (masterBus.excWet) { - const excOn = !!s.excActive; - const wetAmt = excOn ? clamp(s.excDrive, 0, 100) / 100 : 0; - masterBus.excWet.gain.setTargetAtTime(wetAmt * 0.6, now, 0.02); - masterBus.excDry.gain.setTargetAtTime(1.0, now, 0.02); - } - - // 7. Master Rebalance module (M/S gains via L/R crossfeed) - // L' = a·L + b·R, R' = b·L + a·R with a=(mid+side)/2, b=(mid−side)/2 - if (masterBus.gLLr) { - const rebOn = !!s.rebalActive; - const midLin = rebOn ? Math.pow(10, clamp(s.rebalMid, -24, 24) / 20) : 1.0; - const sideLin = rebOn ? Math.pow(10, clamp(s.rebalSide, -24, 24) / 20) : 1.0; - const a = (midLin + sideLin) / 2; - const b = (midLin - sideLin) / 2; - masterBus.gLLr.gain.setTargetAtTime(a, now, 0.01); - masterBus.gRRr.gain.setTargetAtTime(a, now, 0.01); - masterBus.gRLr.gain.setTargetAtTime(b, now, 0.01); - masterBus.gLRr.gain.setTargetAtTime(b, now, 0.01); - } + // Phase 4.5: master chain DSP chạy trong fx_vst_bridge — WebAudio chỉ + // I/O. Param đổi → đẩy TOÀN chain về engine qua SET_PARAM. + if (fxRt && fxRt.ws && fxRt.ws.readyState === 1) { try { fxRtPushAllParams(); } catch (e) {} } } function initMasterBus(ctx) { @@ -1255,19 +1150,6 @@ let _lastMasteringActive = null; let _lastMasteringSig = null; let _lastChainSig = null; -// Module input/output boundary nodes for dynamic chain re-routing -// (mastering_expand.md §II.3 — rebuildAudioGraph). Each module's INTERNAL -// wiring is fixed; only the boundaries get re-connected per chain order. -const MASTER_MODULE_IO = { - eq: { input: 'eqLowFilter', output: 'eqHighFilter' }, - imager: { input: 'imagerInput', output: 'imagerOutput' }, - maximizer: { input: 'maximizerBoostGain', output: 'maximizerCompressor' }, - compressor: { input: 'compInput', output: 'compOutput' }, - limiter: { input: 'limInput', output: 'limOutput' }, - exciter: { input: 'excInput', output: 'excOutput' }, - rebalance: { input: 'rebalInput', output: 'rebalOutput' } -}; - function chainSignature(chainArray) { return (chainArray || []).map(m => (m.type || '') + (m.active ? '1' : '0')).join(','); } @@ -1671,13 +1553,52 @@ const fxRt = { active: false, sessionId: null, ws: null, node: null, inQueue: [] // reorder VST) → restart session với chain mới (fxRtStart early-return khi // active, nếu không so chainKey thì VST mới insert không bao giờ được áp). function fxRtChainKey(chain) { - return (chain || []).map(s => (s.path || '') + '#' + ((s.preset_b64 || '').slice(0, 16))).join('|'); + return (chain || []).map(s => (s.type || '') + ':' + (s.type === 'vst3' ? (s.path || '') + '#' + ((s.preset_b64 || '').slice(0, 16)) : (s.type || ''))).join('|'); } 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 : []; + // Chain hợp nhất (builtin + vst3 theo UI order) — mọi slot active gửi bridge. + return (ms.chain || []).filter(s => s && s.active !== false && (s.type !== 'vst3' || (!ms.vstBypass && s.path && !s.bypass))); +} +// Param master chain entry → C++ BuiltinFx key (mirror Python _BUILTIN_PARAM_MAP). +const FX_RT_PARAM_MAP = { + eq: [['g1', 'eqLowGain'], ['g2', 'eqMid1Gain'], ['g3', 'eqMid2Gain'], ['g4', 'eqHighGain']], + compressor: [['threshold', 'compThreshold'], ['ratio', 'compRatio'], ['makeup', 'compMakeup']], + limiter: [['ceiling', 'limThreshold']], + exciter: [['drive', 'excDrive']], + rebalance: [['mid', 'rebalMid'], ['side', 'rebalSide']], + imager: [['w1', 'w1'], ['w2', 'w2'], ['w3', 'w3'], ['w4', 'w4']], + maximizer: [['boost_db', 'maxGain'], ['soft_clip', 'maxSoftClip'], ['upward', 'maxUpward'], ['ceiling_db', 'ceiling']] +}; +function fxRtEntryParams(entry, ms) { + const out = []; + if (!entry || entry.type === 'vst3') return out; + const src = ms || (window.currentMasteringSettings || {}); + const push = (k, v) => { const n = Number(v); if (isFinite(n)) out.push({ key: k, value: n }); }; + const map = FX_RT_PARAM_MAP[entry.type] || []; + map.forEach(([cppKey, jsKey]) => { + if (entry.params && entry.params[cppKey] !== undefined) push(cppKey, entry.params[cppKey]); + else if (entry.params && entry.params[jsKey] !== undefined) push(jsKey, entry.params[jsKey]); + else if (src[jsKey] !== undefined) push(cppKey, src[jsKey]); + }); + if (entry.type === 'eqpro') { + if (entry.params && entry.params.amount !== undefined) push('amount', entry.params.amount); + const bands = (entry.params && entry.params.bands) || []; + bands.forEach((b, i) => { + ['freq', 'gain', 'q'].forEach(k => { if (b && b[k] !== undefined) push('band' + i + '_' + k, b[k]); }); + }); + } + return out; +} +function fxRtPushAllParams() { + if (!fxRt || !fxRt.ws || fxRt.ws.readyState !== 1) return; + const chain = fxRtChain(); + chain.forEach((s, i) => { + fxRtEntryParams(s, null).forEach(p => { + try { fxRt.ws.send(JSON.stringify({ cmd: 'set_param', slot: i, key: p.key, value: p.value })); } catch (e) {} + }); + }); } async function fxRtStart() { const chain = fxRtChain(); @@ -1693,7 +1614,9 @@ async function fxRtStart() { 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 })); + const chainPayload = chain.map(s => s.type === 'vst3' + ? { type: 'vst3', path: s.path, name: s.name || s.path, preset_b64: s.preset_b64 || '', bypass: false } + : { type: 'builtin', id: s.type, params: (s.params && Object.keys(s.params).length ? s.params : {}), bypass: false }); let st; try { st = await window.SonicAPI.fxRealtimeStart({ fx_chain: chainPayload, sample_rate: ctx.sampleRate || 44100 }); @@ -1714,7 +1637,7 @@ async function fxRtStart() { const ws = new WebSocket(wsUrl); fxRt.ws = ws; ws.binaryType = 'arraybuffer'; - ws.onopen = () => { fxRt.active = true; }; + ws.onopen = () => { fxRt.active = true; try { fxRtPushAllParams(); } catch (e) {} }; ws.onmessage = (ev) => { if (!(ev.data instanceof ArrayBuffer) || !fxRt.node) return; const f = new Float32Array(ev.data); @@ -1728,7 +1651,12 @@ async function fxRtStart() { 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 (!d || !fxRt.ws) return; + if (d.type === 'set_param') { + try { fxRt.ws.send(JSON.stringify({ cmd: 'set_param', slot: d.slot, key: d.key, value: d.value })); } catch (e) {} + return; + } + if (d.type !== 'in') return; if (fxRt.inQueue.length > 64) fxRt.inQueue.shift(); fxRt.inQueue.push(d.data); }; @@ -1743,16 +1671,12 @@ async function fxRtStart() { const d = fxRt.inQueue.shift(); try { ws.send(d); } catch (e) { fxRt.inQueue.unshift(d); } }, blockMs); - try { masterBus.output.disconnect(masterBus.analyser); } catch (e) {} - // VU meter + peak graph (leftAnalyser/rightAnalyser qua splitter) đo âm - // SAU VST realtime: ngắt splitter khỏi output, nối vào fxRt.node để meter - // hiển thị audio data đã qua chuỗi VST (user requirement — peak/VU realtime). - if (masterBus.splitter) { - try { masterBus.output.disconnect(masterBus.splitter); } catch (e) {} - } - masterBus.output.connect(fxRt.node); - fxRt.node.connect(masterBus.analyser); - if (masterBus.splitter) fxRt.node.connect(masterBus.splitter); + // Phase 4.2: fader SAU worklet — worklet chèn giữa outputAnalyser (chain + // output) và output (fader/destination). output → analyser/splitter giữ + // nguyên (meter đo âm SAU fader). + try { masterBus.outputAnalyser.disconnect(masterBus.output); } catch (e) {} + masterBus.outputAnalyser.connect(fxRt.node); + fxRt.node.connect(masterBus.output); fxRt.chainKey = ck; } catch (e) { console.warn('[FX RT] setup fail:', e); @@ -1766,12 +1690,9 @@ function fxRtStop(silent) { try { if (fxRt.node) { fxRt.node.disconnect(); - if (masterBus && masterBus.output && masterBus.analyser) { - try { masterBus.output.connect(masterBus.analyser); } catch (e) {} - // Khôi phục VU meter splitter về output (pre-VST) khi tắt realtime. - if (masterBus.splitter) { - try { masterBus.output.connect(masterBus.splitter); } catch (e) {} - } + if (masterBus && masterBus.outputAnalyser && masterBus.output) { + try { masterBus.outputAnalyser.disconnect(masterBus.output); } catch (e) {} + try { masterBus.outputAnalyser.connect(masterBus.output); } catch (e) {} } fxRt.node.port.onmessage = null; fxRt.node = null; @@ -1814,8 +1735,15 @@ function fxRtGuiPushShm() { } function fxRtTrackChain(track) { if (!track) return []; - const chain = activeVstChain(track.vstFxChain || []); - return (chain.length > 0 && !track.vstFxBypass) ? chain : []; + let chain = (track.fxChain || []).filter(s => s && s.active !== false && (s.type !== 'vst3' || (!track.vstFxBypass && s.path && !s.bypass))); + // Legacy: project cũ chỉ có vstFxChain riêng — append sau builtin. + if (!chain.some(s => s && s.type === 'vst3') && !track.vstFxBypass) { + chain = chain.concat(activeVstChain(track.vstFxChain || [])); + } + return chain; +} +function fxRtTrackChainKey(chain) { + return (chain || []).map(s => (s.type || '') + ':' + (s.type === 'vst3' ? (s.path || '') + '#' + ((s.preset_b64 || '').slice(0, 16)) : (s.type || ''))).join('|'); } function fxRtTrackPaths(track) { const paths = []; @@ -1838,65 +1766,16 @@ const TRACK_EQ_PRESETS = { // between inputAnalyser (chain input) and outputAnalyser (chain output). function rebuildMasteringGraph(activate, chainArray) { if (!masterBus) return; - const active = !!(activate); try { - // 1. Disconnect all module boundary outputs (breaks static + previous dynamic links) + // Phase 4.2: master chain xử lý TOÀN bộ trong fx_vst_bridge — WebAudio + // master là pass-through (inputAnalyser → outputAnalyser). masterBus.inputAnalyser.disconnect(); - masterBus.eqHighFilter.disconnect(); - masterBus.imagerOutput.disconnect(); - masterBus.maximizerCompressor.disconnect(); - masterBus.compOutput.disconnect(); - masterBus.limOutput.disconnect(); - masterBus.excOutput.disconnect(); - masterBus.rebalOutput.disconnect(); - - if (!active) { - // 2a. No mastering → straight chain: inputAnalyser → outputAnalyser - masterBus.inputAnalyser.connect(masterBus.outputAnalyser); - masterBus.masteringActive = false; - return; - } - - // 2b. Filter active modules - const activeMods = (chainArray || []).filter(m => m && m.active); - if (activeMods.length === 0) { - masterBus.inputAnalyser.connect(masterBus.outputAnalyser); - masterBus.masteringActive = true; - return; - } - - // 3. Wire in series: Input → mod[0].input → mod[0].output → mod[1].input → … → Output - let prev = masterBus.inputAnalyser; - // Dynamic modules (eqpro): rebuilt on every graph rebuild; instances kept - // in masterBus.eqProInstances so the Mastering panel can patch them live. - const eqProStore = masterBus.eqProInstances = masterBus.eqProInstances || {}; - Object.keys(eqProStore).forEach(k => { try { eqProStore[k].destroy && eqProStore[k].destroy(); } catch (e) { } }); - Object.keys(eqProStore).forEach(k => delete eqProStore[k]); - activeMods.forEach(mod => { - if (mod.type === 'vst3') { - // VST FX chạy NGOÀI (fx_vst_bridge) — không có node WebAudio trong - // master chain: pass-through, prev giữ nguyên. - return; - } - if (mod.type === 'eqpro') { - const m = createEqProModule(getAudioContext(), mod.params || {}); - eqProStore[mod.id] = m; - prev.connect(m.input); - prev = m.output; - } else { - const io = MASTER_MODULE_IO[mod.type] || MASTER_MODULE_IO.eq; - const inNode = masterBus[io.input]; - if (inNode) prev.connect(inNode); - prev = masterBus[io.output] || prev; - } - }); - prev.connect(masterBus.outputAnalyser); - masterBus.masteringActive = true; + masterBus.inputAnalyser.connect(masterBus.outputAnalyser); + masterBus.masteringActive = !!(activate); } catch (e) { console.warn('rebuildMasteringGraph error:', e); } } - function toggleMasteringOnMaster(activate, isBypassed) { if (!masterBus) return; @@ -11184,8 +11063,19 @@ const serializeTracksList = (tracksList, secondsPerBar) => { soundfont_program: t.soundfont_program !== undefined ? t.soundfont_program : (t.synth_engine ? t.synth_engine.soundfont_program : null), synth_engine: t.synth_engine || undefined, midi_channel: t.midiChannel !== undefined ? t.midiChannel : null, - fx_chain: (t.fxChain || []).map(m => typeof m === 'string' ? { type: m, active: true } : { type: m.type, active: m.active !== false }), - vst_fx_chain: (t.vstFxChain || []).map(s => ({ type: s.type || 'vst3', path: s.path || '', name: s.name || '', preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '', bypass: !!s.bypass })), + fx_chain: (t.fxChain || []).map(m => { + if (typeof m === 'string') return { type: m, active: true }; + const o = { type: m.type, active: m.active !== false }; + if (m.type === 'vst3') { + o.path = m.path || ''; o.name = m.name || ''; o.preset_b64 = _capturedPresetByPath[m.path] || m.preset_b64 || ''; o.bypass = !!m.bypass; + } else if (m.params && Object.keys(m.params).length) o.params = m.params; + return o; + }), + vst_fx_chain: (() => { + const chainVst = (t.fxChain || []).filter(m => m && m.type === 'vst3'); + const vstSrc = chainVst.length ? chainVst : (t.vstFxChain || []); + return vstSrc.map(s => ({ type: 'vst3', path: s.path || '', name: s.name || '', preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '', bypass: !!s.bypass })); + })(), vst_fx_bypass: !!t.vstFxBypass, vst_gui_open_path: (t.id === _persistFxGuiTrackId && _persistFxGuiOpen && _persistFxGuiOpen.path) ? _persistFxGuiOpen.path : (t.vstGuiOpenPath || null), vst_gui_open_name: (t.id === _persistFxGuiTrackId && _persistFxGuiOpen && _persistFxGuiOpen.name) ? _persistFxGuiOpen.name : (t.vstGuiOpenName || null), @@ -11288,8 +11178,17 @@ const deserializeTracksList = (schemaTracks, secondsPerBar, sectionStore, visite 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 })), + fxChain: (() => { + const parsed = (t.fx_chain || []).map(m => typeof m === 'string' ? { type: m, active: true } : ({ type: m.type || m, active: m.active !== false, ...(m.params ? { params: m.params } : {}), ...(m.type === 'vst3' ? { path: m.path || '', name: m.name || '', preset_b64: m.preset_b64 || '', bypass: !!m.bypass } : {}) })); + const seen = new Set(parsed.filter(m => m.type === 'vst3').map(m => m.path)); + (t.vst_fx_chain || []).forEach(s => { if (s && s.path && !seen.has(s.path)) { parsed.push({ type: 'vst3', path: s.path || '', name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass, active: true }); seen.add(s.path); } }); + return parsed; + })(), + vstFxChain: (() => { + const fromChain = (t.fx_chain || []).filter(m => m && m.type === 'vst3'); + const src = fromChain.length ? fromChain : (t.vst_fx_chain || []); + return src.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, @@ -11509,13 +11408,26 @@ const deserializeProjectFromSchema = (schemaObj) => { tracks: restoredTracks, sessionTabs: restoredSessionTabs, subTabs: restoredSubTabs, - masteringSettings: { - ..._migratedMastering, - vstFxChain: _masterFx.map(s => ({ type: s.type || 'vst3', path: s.path || '', name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass })), - vstBypass: !!_masterBus.bypass, - gui_open_path: (schemaObj.mastering_settings && schemaObj.mastering_settings.gui_open_path) ? schemaObj.mastering_settings.gui_open_path : null, - gui_open_name: (schemaObj.mastering_settings && schemaObj.mastering_settings.gui_open_name) ? schemaObj.mastering_settings.gui_open_name : null - }, + masteringSettings: (() => { + const migrated = { ..._migratedMastering }; + const _masterVstSources = []; + (migrated.vstFxChain || []).forEach(s => { if (s && s.path && !_masterVstSources.some(x => x.path === s.path)) _masterVstSources.push(s); }); + _masterFx.forEach(s => { if (s && s.path && !_masterVstSources.some(x => x.path === s.path)) _masterVstSources.push(s); }); + const _finalChain = [...(migrated.chain || [])]; + _masterVstSources.forEach(s => { + if (!_finalChain.some(m => m.type === 'vst3' && m.path === s.path)) { + _finalChain.push({ id: 'mod_vst_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8), type: 'vst3', path: s.path, name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass, active: true }); + } + }); + return { + ...migrated, + chain: _finalChain, + vstFxChain: _finalChain.filter(m => m.type === 'vst3').map(s => ({ type: 'vst3', path: s.path || '', name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass })), + vstBypass: !!_masterBus.bypass, + gui_open_path: (schemaObj.mastering_settings && schemaObj.mastering_settings.gui_open_path) ? schemaObj.mastering_settings.gui_open_path : null, + gui_open_name: (schemaObj.mastering_settings && schemaObj.mastering_settings.gui_open_name) ? schemaObj.mastering_settings.gui_open_name : null + }; + })(), masterVolume: (typeof _masterBus.volume_db === 'number' && isFinite(_masterBus.volume_db)) ? _masterBus.volume_db : 0, zoom: schemaObj.metadata && schemaObj.metadata.zoom ? parseFloat(schemaObj.metadata.zoom) : 1.0 }; @@ -12300,14 +12212,16 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => { const chain = track.fxChain || []; - // ── VST FX chain (native_bridge --render-fx, áp dụng khi Export/Offline render) ── - const vstChain = (track.vstFxChain || []); + // ── VST FX chain (native_bridge --render-fx) — tham chiếu từ unified chain ── + const vstChain = chain.filter(m => m && m.type === 'vst3'); // 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 }); if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id, { vstFxChain: next, vstFxBypass: vstBypass }); }; + const unifiedChain = chain.map((m, i) => ({ kind: m && m.type === 'vst3' ? 'vst' : 'builtin', m, i })); + const setVstChain = (next) => { + const builtin = chain.filter(m => !m || m.type !== 'vst3'); + const merged = [...builtin, ...next.map(s => ({ type: 'vst3', path: s.path || '', name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass }))]; + if (onUpdateTrack) onUpdateTrack(track.id, { fxChain: merged, vstFxChain: next, vstFxBypass: vstBypass }); + if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id, { fxChain: merged, 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) => { if (_persistFxGuiOpen && _persistFxGuiOpen.url) _fxGuiUnregisterSource(_persistFxGuiOpen.url); _persistFxGuiOpen = v; _persistFxGuiTrackId = v ? (track && track.id) : null; if (v && v.url) _fxGuiRegisterSource(v.url, track && track.id); setFxGuiOpenState(v); }; @@ -12332,7 +12246,7 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => { // 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); + const s = (track.fxChain || []).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, shm: fxRtTrackShmName(track && track.id) }).then(r => { if (r && r.embed_url) setFxGuiOpen({ path: s.path, url: r.embed_url }); @@ -12627,7 +12541,7 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path); draggable 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; }} + 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.m); setChain(nb); setVstChain(nv); if (mv.kind === 'vst') setActiveVstIdx(nv.indexOf(mv.m)); } 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'}`}>
@@ -12645,17 +12559,17 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
); } - const s = u.s; + const s = u.m; return (
{ 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'}`}> + 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.m); setChain(nb); setVstChain(nv); if (mv.kind === 'vst') setActiveVstIdx(nv.indexOf(mv.m)); } dragChainIndexRef.current = null; }} + onClick={() => { closeFxGui(); setActiveVstIdx(vstChain.indexOf(s)); }} + className={`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all shrink-0 ${activeVstIdx === vstChain.indexOf(s) ? 'border-2 border-teal-400 bg-slate-800' : 'bg-slate-900 border border-teal-900/70 hover:border-teal-600'}`}>
-
@@ -12663,7 +12577,7 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
{u.i + 1}. {s.preset_b64 ? '✓ preset' : 'no preset'}
-
@@ -12923,7 +12837,20 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin }).catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen, ozState && ozState.gui_open_path]); - const setMasterVstChain = (next) => setOzState(prev => ({ ...prev, vstFxChain: next })); + const setMasterVstChain = (next) => setOzState(prev => { + const chain = prev.chain || []; + const out = []; + let vi = 0; + const mk = (s, i) => ({ id: 'mod_vst_' + Date.now() + '_' + i + '_' + Math.random().toString(36).slice(2, 8), type: 'vst3', path: s.path, name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass, active: true }); + chain.forEach(m => { + if (m.type === 'vst3') { + const s = next[vi]; + if (s) { out.push(mk(s, vi)); vi++; } + } else out.push(m); + }); + for (; vi < next.length; vi++) out.push(mk(next[vi], vi)); + return { ...prev, vstFxChain: next, chain: out }; + }); // LIVE master FX xử lý bởi fxRtStart() (SHM loop qua fx_vst_bridge // --realtime-fx) — KHÔNG push chain xuống native bridge (setFxChain): // nếu push, MIDI/VSTi instrument bị xử lý 2 lần (native bridge + SHM loop) @@ -13507,7 +13434,8 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat const chain = [...prev.chain]; const moved = chain.splice(fromIdx, 1)[0]; chain.splice(toIdx, 0, moved); - return { ...prev, chain }; + const vstFxChain = chain.filter(m => m && m.type === 'vst3').map(s => ({ type: 'vst3', path: s.path || '', name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass })); + return { ...prev, chain, vstFxChain }; }); }; const addModuleToChain = (type) => { @@ -13568,21 +13496,8 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat const applyEQPreset = (presetKey) => { const preset = EQ_PRESET_LIBRARY[presetKey]; if (!preset) return; - const bus = masterBus; - if (bus && bus.eqLowFilter) { - const now = audioCtx ? audioCtx.currentTime : 0; - const filters = [bus.eqLowFilter, bus.eqMid1Filter, bus.eqMid2Filter, bus.eqHighFilter]; - preset.bands.forEach((bd, i) => { - const f = filters[i]; - if (!f) return; - try { - // setValueAtTime (không automation) — tránh "BiquadFilterNode: state is bad" - f.frequency.setValueAtTime(bd.freq, now); - f.gain.setValueAtTime(bd.gain, now); - f.Q.setValueAtTime(bd.q, now); - } catch (e) {} - }); - } + // Phase 4.5: EQ DSP chạy trong bridge — preset chỉ sync state; engine + // nhận qua fxRtPushAllParams() ở applyMasteringSettings. // Sync UI knobs + canvas: [eqLowGain, eqMid1Gain, eqMid2Gain, eqHighGain] setOzState(prev => ({ ...prev, @@ -13771,9 +13686,24 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat track={null} params={cm.params || {}} onChange={(next) => updateChainEntryParams(cm.id, next)} - getModule={() => masterBus && masterBus.eqProInstances ? masterBus.eqProInstances[cm.id] : null} - spectrumModules={() => { const m = masterBus && masterBus.eqProInstances ? masterBus.eqProInstances[cm.id] : null; return m ? [m] : []; }} - applyTo={(fn) => { const m = masterBus && masterBus.eqProInstances ? masterBus.eqProInstances[cm.id] : null; if (m) fn(m); }} + getModule={() => null} + spectrumModules={() => []} + applyTo={(fn) => { + const chain = fxRtChain(); + const slot = chain.findIndex(m => m && m.type === 'eqpro'); + if (slot < 0) return; + fn({ + setBand: (idx, patch) => { + Object.keys(patch || {}).forEach(k => { if (fxRt && fxRt.ws && fxRt.ws.readyState === 1) fxRt.ws.send(JSON.stringify({ cmd: 'set_param', slot, key: 'band' + idx + '_' + k, value: patch[k] })); }); + }, + setAmount: (v) => { if (fxRt && fxRt.ws && fxRt.ws.readyState === 1) fxRt.ws.send(JSON.stringify({ cmd: 'set_param', slot, key: 'amount', value: v })); }, + syncBands: (bands) => { + (bands || []).forEach((b, i) => { + ['freq', 'gain', 'q'].forEach(k => { if (fxRt && fxRt.ws && fxRt.ws.readyState === 1) fxRt.ws.send(JSON.stringify({ cmd: 'set_param', slot, key: 'band' + i + '_' + k, value: b[k] })); }); + }); + } + }); + }} />; })()} @@ -23028,15 +22958,8 @@ const App = () => { // FX Power (nút PWR): ON = mọi items con (midi/section/audioclip) qua FX // Rack Panel chain; OFF = bỏ toàn bộ track FX (fxChain + chorus/reverb). const fxEnabled = track.fxActive !== false; - const chainMods = fxEnabled ? fxChain.filter(m => m && m.active !== false).map(m => { - try { return createTrackFxModule(m.type || m, context, m.params); } catch (e) { return null; } - }).filter(Boolean) : []; - let fxChainTail = fxEntry; - chainMods.forEach(mod => { - fxChainTail.connect(mod.input); - fxChainTail = mod.output; - }); - fxChainTail.connect(fxLegacyIn); + // Phase 4.3: track builtin DSP chạy trong bridge — WebAudio pass-through. + fxEntry.connect(fxLegacyIn); if (fxEnabled && track.fxType === 'chorus') { const fxInput = context.createGain(); fxLegacyIn.connect(fxInput); @@ -23090,18 +23013,10 @@ const App = () => { sfRouteGain.connect(masterBus.input); sfDryGain.connect(masterBus.dryInput); sfOut.connect(sfAnalyser); - const sfModsBuilt = fxEnabled ? fxChain.filter(m => m && m.active !== false).map(m => { - try { return createTrackFxModule(m.type || m, context, m.params); } catch (e) { return null; } - }).filter(Boolean) : []; - sfMods = sfModsBuilt; - let sfTail = sfEntry; - sfMods.forEach(mod => { - sfTail.connect(mod.input); - sfTail = mod.output; - }); - sfTail.connect(sfOut); + sfMods = []; + sfEntry.connect(sfOut); } - node = { gainNode, pannerNode, fxStopFn, analyserNode, route, fxEntry, fxLegacyIn, scopeAnalyserL, scopeAnalyserR, sfEntry, sfOut, sfPan, sfAnalyser, sfRouteGain, sfDryGain, fxMods: chainMods, sfMods }; + node = { gainNode, pannerNode, fxStopFn, analyserNode, route, fxEntry, fxLegacyIn, scopeAnalyserL, scopeAnalyserR, sfEntry, sfOut, sfPan, sfAnalyser, sfRouteGain, sfDryGain, fxMods: [], sfMods: [] }; // Realtime mute/solo: apply the track's current mute/solo/volume state to // the fresh node so items of muted/soloed tracks start correctly. const trackList = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : [track]; @@ -23132,30 +23047,13 @@ const App = () => { 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; + node.fxEntry.connect(node.fxLegacyIn); + node.fxMods = []; // 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); + node.sfEntry.connect(node.sfOut); + node.sfMods = []; } if (node.sfPan && getAudioContext()) { node.sfPan.pan.setTargetAtTime((track.pan ?? 0) / 100, getAudioContext().currentTime, 0.02); @@ -23186,7 +23084,9 @@ const App = () => { } 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 })); + const chainPayload = chain.map(s => s.type === 'vst3' + ? { type: 'vst3', path: s.path, name: s.name || s.path, preset_b64: s.preset_b64 || '', bypass: false } + : { type: 'builtin', id: s.type, params: (s.params && Object.keys(s.params).length ? s.params : {}), bypass: false }); let st; try { st = await window.SonicAPI.fxRealtimeStart({ fx_chain: chainPayload, sample_rate: ctx.sampleRate || 44100 }); @@ -23194,7 +23094,7 @@ const App = () => { 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 }; + const entry = { sessionId: st.session_id, ws: null, node: null, ready: false, path, spliced: null, chainKey: fxRtTrackChainKey(chain), paramsBySlot: chain.map(s => fxRtEntryParams(s, null)), inQueue: [], pumpTimer: null }; fxRtTracks[key] = entry; fxRtGuiPushShm(); try { @@ -23207,7 +23107,13 @@ const App = () => { 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.onopen = () => { + entry.ready = true; + fxRtTrackSplice(trackId, path); + try { + (entry.paramsBySlot || []).forEach((pl, slot) => pl.forEach(p => ws.send(JSON.stringify({ cmd: 'set_param', slot, key: p.key, value: p.value })))); + } catch (e) {} + }; ws.onmessage = (ev) => { if (!(ev.data instanceof ArrayBuffer) || !entry.node) return; const f = new Float32Array(ev.data); @@ -23221,7 +23127,12 @@ const App = () => { 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 (!d || !entry.ws) return; + if (d.type === 'set_param') { + try { entry.ws.send(JSON.stringify({ cmd: 'set_param', slot: d.slot, key: d.key, value: d.value })); } catch (e) {} + return; + } + if (d.type !== 'in') return; if (entry.inQueue.length > 64) entry.inQueue.shift(); entry.inQueue.push(d.data); }; @@ -23251,9 +23162,7 @@ const App = () => { 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 tail = path === 'audio' ? node.fxEntry : node.sfEntry; const dest = path === 'audio' ? node.fxLegacyIn : node.sfOut; if (!tail || !dest) return; try { @@ -23285,6 +23194,7 @@ const App = () => { const fxRtTrackReconcile = (trackId, node, track) => { try { const chain = fxRtTrackChain(track); + const chainKey = fxRtTrackChainKey(chain); const wantPaths = chain.length ? fxRtTrackPaths(track) : []; const prefix = trackId + ':'; Object.keys(fxRtTracks).forEach(k => { @@ -23297,6 +23207,7 @@ const App = () => { const key = trackId + ':' + p; const entry = fxRtTracks[key]; if (!entry) fxRtTrackStart(trackId, track, p); + else if (entry.chainKey !== chainKey) { fxRtTrackStop(trackId, p, true); 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); @@ -23566,19 +23477,30 @@ const App = () => { // UI helper: fetch a live module instance (e.g. 'eqpro') from the active track // node so interactive panels can drive its DSP params in realtime. window.__getTrackFxModule = (trackId, type) => { - const node = activeTrackNodesRef.current[trackId]; - if (!node) return null; - const found = (node.fxMods || []).find(m => m && m.type === type); - return found || null; + if (type !== 'eqpro') return null; + const findSlot = () => { + const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks; + const track = list.find(t => t.id === trackId); + return track ? fxRtTrackChain(track).findIndex(m => m && m.type === 'eqpro') : -1; + }; + const sendAll = (fn) => { + const slot = findSlot(); + if (slot < 0) return; + Object.keys(fxRtTracks).forEach(k => { + if (!k.startsWith(trackId + ':')) return; + const e = fxRtTracks[k]; + if (e && e.ws && e.ws.readyState === 1) fn(e.ws, slot); + }); + }; + return { + setBand: (idx, patch) => sendAll((ws, slot) => Object.keys(patch || {}).forEach(k => { try { ws.send(JSON.stringify({ cmd: 'set_param', slot, key: 'band' + idx + '_' + k, value: patch[k] })); } catch (e) {} })), + setAmount: (v) => sendAll((ws, slot) => { try { ws.send(JSON.stringify({ cmd: 'set_param', slot, key: 'amount', value: v })); } catch (e) {} }), + syncBands: (bands) => sendAll((ws, slot) => (bands || []).forEach((b, i) => ['freq', 'gain', 'q'].forEach(k => { try { ws.send(JSON.stringify({ cmd: 'set_param', slot, key: 'band' + i + '_' + k, value: b[k] })); } catch (e) {} }))) + }; }; // Same for the soundfont (MIDI) module instances — lets the EQ PRO panel drive // BOTH the audio-clip chain and the SF chain in realtime. - window.__getTrackSfFxModule = (trackId, type) => { - const node = activeTrackNodesRef.current[trackId]; - if (!node) return null; - const found = (node.sfMods || []).find(m => m && m.type === type); - return found || null; - }; + window.__getTrackSfFxModule = (trackId, type) => window.__getTrackFxModule(trackId, type); const getOrCreateSubTrackNode = (track, subTrack, context) => { if (!track || !subTrack) return null; const subKey = track.id + '_sub_' + subTrack.id; diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index fee57dc..8ce03f7 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -159,34 +159,9 @@ return Math.max(-1,Math.min(1,sumLR/Math.sqrt(sumL2*sumR2)));}catch(e){return 1. // Re-applying identical values in rapid bursts is "fast parameter automation" // and makes Chromium flag the biquad EQ filters as unstable ("state is bad"). // Only touch the graph when a value actually changed. -const sig=[s.masterConnected,s.isBypassed,s.eqActive,s.eqLowGain,s.eqMid1Gain,s.eqMid2Gain,s.eqHighGain,s.imagerActive,s.w1,s.w2,s.w3,s.w4,s.maximizerActive,s.maxGain,s.maxSoftClip,s.maxUpward,s.ceiling,s.compActive,s.compThreshold,s.compRatio,s.compMakeup,s.limActive,s.limThreshold,s.excActive,s.excDrive,s.rebalActive,s.rebalMid,s.rebalSide].join('|');if(sig===_lastMasteringSig)return;_lastMasteringSig=sig;const now=audioCtx.currentTime;// Clamp every parameter so a stale/incomplete settings object can never push -// NaN or an extreme value into the biquad filters — that puts the master -// chain into a bad state and silences ALL audio (the "mất soundfont" symptom). -const clamp=(v,lo,hi)=>{const n=Number(v);if(!isFinite(n))return 0;// missing/NaN → neutral, never a filter-breaking value -return Math.min(hi,Math.max(lo,n));};// 1. EQ Settings -// cancelScheduledValues + a slower time constant keeps rapid slider drags -// from piling up automation events on the biquad filters (the trigger for -// Chromium's "BiquadFilterNode: state is bad"). -// ⚠️ Guard NaN: setValueAtTime(NaN) trên biquad → Chromium "BiquadFilterNode: -// state is bad" + câm. Field settings undefined/NaN → mặc định 0. -const _g=(v,lo,hi)=>typeof v==='number'&&isFinite(v)?clamp(v,lo,hi):0;masterBus.eqLowFilter.gain.cancelScheduledValues(now);masterBus.eqLowFilter.gain.setValueAtTime(s.eqActive?_g(s.eqLowGain,-24,24):0,now);masterBus.eqMid1Filter.gain.cancelScheduledValues(now);masterBus.eqMid1Filter.gain.setValueAtTime(s.eqActive?_g(s.eqMid1Gain,-24,24):0,now);masterBus.eqMid2Filter.gain.cancelScheduledValues(now);masterBus.eqMid2Filter.gain.setValueAtTime(s.eqActive?_g(s.eqMid2Gain,-24,24):0,now);masterBus.eqHighFilter.gain.cancelScheduledValues(now);masterBus.eqHighFilter.gain.setValueAtTime(s.eqActive?_g(s.eqHighGain,-24,24):0,now);// 2. Imager Settings (Mid/Side width per band — imager_spec.md) -// Width % semantics per the guide: 0% = MONO (S × 0), 100% = original -// (S × 1), 200% = double width (S × 2). The L/R crossfeed gains below are -// exactly equivalent to M/S scaling: L'=g1·L+g2·R, R'=g1·R+g2·L with -// g1=(w+100)/200, g2=(100−w)/200 → Mid (L+R)/2 untouched, Side (L−R)/2 -// scaled by w/100. Bands: 1=20-100Hz, 2=100Hz-1kHz, 3=1k-6kHz, 4=6k-20kHz. -const updateImagerBand=(w,active,gainLL,gainRL,gainLR,gainRR)=>{const widthVal=s.imagerActive&&active?clamp(w,0,200):100;const g1=(widthVal+100)/200;const g2=(100-widthVal)/200;gainLL.gain.setTargetAtTime(g1,now,0.01);gainRR.gain.setTargetAtTime(g1,now,0.01);gainRL.gain.setTargetAtTime(g2,now,0.01);gainLR.gain.setTargetAtTime(g2,now,0.01);};updateImagerBand(s.w1,true,masterBus.gainLL1,masterBus.gainRL1,masterBus.gainLR1,masterBus.gainRR1);updateImagerBand(s.w2,true,masterBus.gainLL2,masterBus.gainRL2,masterBus.gainLR2,masterBus.gainRR2);updateImagerBand(s.w3,true,masterBus.gainLL3,masterBus.gainRL3,masterBus.gainLR3,masterBus.gainRR3);updateImagerBand(s.w4,true,masterBus.gainLL4,masterBus.gainRL4,masterBus.gainLR4,masterBus.gainRR4);// 3. Maximizer Settings -const boostLinear=s.maximizerActive?Math.pow(10,clamp(s.maxGain,-60,30)/20):1.0;masterBus.maximizerBoostGain.gain.setTargetAtTime(boostLinear,now,0.01);// Soft Clipper (identity passthrough when off — never null curve). -// Unity-gain curve: bo gain an cua atan (slope ~2.1x cu cong +6 dB len -// maxGain -> tin hieu >~-12 dBFS bi hard-clip meo "be"). -if(s.maximizerActive&&s.maxSoftClip>0){masterBus.maximizerSoftClipper.curve=makeSoftClipCurve(s.maxSoftClip);}else{masterBus.maximizerSoftClipper.curve=new Float32Array([-1,1]);}// Upward Compressor -const upwardGainLinear=s.maximizerActive&&s.maxUpward>0?Math.pow(10,clamp(s.maxUpward,0,30)/20)-1.0:0.0;masterBus.upwardGain.gain.setTargetAtTime(upwardGainLinear,now,0.01);// Limiter Threshold (WaveShaper ceiling — _setCeiling rebuild curve) -const ceilingVal=s.maximizerActive?clamp(s.ceiling,-60,0):-0.1;if(masterBus.maximizerCompressor._setCeiling)masterBus.maximizerCompressor._setCeiling(ceilingVal);// 4. Bus Compressor module (mastering_expand.md §II.2) -if(masterBus.compNode){const compOn=!!s.compActive;masterBus.compNode.threshold.setTargetAtTime(compOn?clamp(s.compThreshold,-60,0):0,now,0.02);masterBus.compNode.ratio.setTargetAtTime(compOn?clamp(s.compRatio,1,20):1,now,0.02);masterBus.compMakeup.gain.setTargetAtTime(compOn?Math.pow(10,clamp(s.compMakeup,0,12)/20):1.0,now,0.02);}// 5. Brickwall Limiter module (WaveShaper tanh — threshold = mức clip; OFF = identity) -if(masterBus.limNode){const limOn=!!s.limActive;if(limOn){if(masterBus.limNode._setThreshold)masterBus.limNode._setThreshold(clamp(s.limThreshold,-24,0));}else{try{masterBus.limNode.curve=new Float32Array([-1,1]);}catch(e){}}}// 6. Harmonic Exciter module (dry/wet mix; dry stays 1.0 for subtle warmth) -if(masterBus.excWet){const excOn=!!s.excActive;const wetAmt=excOn?clamp(s.excDrive,0,100)/100:0;masterBus.excWet.gain.setTargetAtTime(wetAmt*0.6,now,0.02);masterBus.excDry.gain.setTargetAtTime(1.0,now,0.02);}// 7. Master Rebalance module (M/S gains via L/R crossfeed) -// L' = a·L + b·R, R' = b·L + a·R with a=(mid+side)/2, b=(mid−side)/2 -if(masterBus.gLLr){const rebOn=!!s.rebalActive;const midLin=rebOn?Math.pow(10,clamp(s.rebalMid,-24,24)/20):1.0;const sideLin=rebOn?Math.pow(10,clamp(s.rebalSide,-24,24)/20):1.0;const a=(midLin+sideLin)/2;const b=(midLin-sideLin)/2;masterBus.gLLr.gain.setTargetAtTime(a,now,0.01);masterBus.gRRr.gain.setTargetAtTime(a,now,0.01);masterBus.gRLr.gain.setTargetAtTime(b,now,0.01);masterBus.gLRr.gain.setTargetAtTime(b,now,0.01);}}function initMasterBus(ctx){if(masterBus)return masterBus;// Clamp every filter frequency below Nyquist (0.45 * sampleRate). A biquad +const sig=[s.masterConnected,s.isBypassed,s.eqActive,s.eqLowGain,s.eqMid1Gain,s.eqMid2Gain,s.eqHighGain,s.imagerActive,s.w1,s.w2,s.w3,s.w4,s.maximizerActive,s.maxGain,s.maxSoftClip,s.maxUpward,s.ceiling,s.compActive,s.compThreshold,s.compRatio,s.compMakeup,s.limActive,s.limThreshold,s.excActive,s.excDrive,s.rebalActive,s.rebalMid,s.rebalSide].join('|');if(sig===_lastMasteringSig)return;_lastMasteringSig=sig;// Phase 4.5: master chain DSP chạy trong fx_vst_bridge — WebAudio chỉ +// I/O. Param đổi → đẩy TOÀN chain về engine qua SET_PARAM. +if(fxRt&&fxRt.ws&&fxRt.ws.readyState===1){try{fxRtPushAllParams();}catch(e){}}}function initMasterBus(ctx){if(masterBus)return masterBus;// Clamp every filter frequency below Nyquist (0.45 * sampleRate). A biquad // with frequency ≥ Nyquist gets NaN coefficients → "BiquadFilterNode: state // is bad" → the whole mastering chain outputs silence (IN peak yes, OUT no). // Low sample-rate devices (8/11/16 kHz audio drivers) would otherwise break @@ -233,10 +208,7 @@ if(window.currentMasteringSettings){try{// Reset sig-cache: chain MỚI (biquad/ // INIT (gain 0, width 100…) — applyMasteringSettings early-return vì // _lastMasteringSig không đổi (module-level, persist qua recreation) → // chain FLAT ("spectrum hiển thị nhưng không xử lí âm thanh"). -_lastMasteringSig=null;toggleMasteringOnMaster(window.currentMasteringSettings.masterConnected,window.currentMasteringSettings.isBypassed);applyMasteringSettings(window.currentMasteringSettings);}catch(e){console.warn('initMasterBus apply mastering error:',e);}}return masterBus;}function setMasterVolume(linear){if(masterBus)masterBus.output.gain.setValueAtTime(linear,audioCtx.currentTime);}let _lastMasteringActive=null;let _lastMasteringSig=null;let _lastChainSig=null;// Module input/output boundary nodes for dynamic chain re-routing -// (mastering_expand.md §II.3 — rebuildAudioGraph). Each module's INTERNAL -// wiring is fixed; only the boundaries get re-connected per chain order. -const MASTER_MODULE_IO={eq:{input:'eqLowFilter',output:'eqHighFilter'},imager:{input:'imagerInput',output:'imagerOutput'},maximizer:{input:'maximizerBoostGain',output:'maximizerCompressor'},compressor:{input:'compInput',output:'compOutput'},limiter:{input:'limInput',output:'limOutput'},exciter:{input:'excInput',output:'excOutput'},rebalance:{input:'rebalInput',output:'rebalOutput'}};function chainSignature(chainArray){return(chainArray||[]).map(m=>(m.type||'')+(m.active?'1':'0')).join(',');}// ── Track FX module factory (mastering_expand.md §II.4) ── +_lastMasteringSig=null;toggleMasteringOnMaster(window.currentMasteringSettings.masterConnected,window.currentMasteringSettings.isBypassed);applyMasteringSettings(window.currentMasteringSettings);}catch(e){console.warn('initMasterBus apply mastering error:',e);}}return masterBus;}function setMasterVolume(linear){if(masterBus)masterBus.output.gain.setValueAtTime(linear,audioCtx.currentTime);}let _lastMasteringActive=null;let _lastMasteringSig=null;let _lastChainSig=null;function chainSignature(chainArray){return(chainArray||[]).map(m=>(m.type||'')+(m.active?'1':'0')).join(',');}// ── Track FX module factory (mastering_expand.md §II.4) ── // The SAME module DSP used in the mastering chain, instantiated per-track for // the [FX] button on track strips. Returns { input, output, nodes, dispose }. // ── Track FX module factory (mastering_expand.md §II.4 / unified_fx_rack_panel.md) ── @@ -290,17 +262,18 @@ const len=Math.floor(pcm.length/channels);const bps=4;const dataBytes=len*bps*ch const fxRt={active:false,sessionId:null,ws:null,node:null,inQueue:[],pumpTimer:null,chainKey:null};// Signature của chain VST realtime — phát hiện chain ĐỔI (insert/remove/bypass/ // reorder VST) → restart session với chain mới (fxRtStart early-return khi // active, nếu không so chainKey thì VST mới insert không bao giờ được áp). -function fxRtChainKey(chain){return(chain||[]).map(s=>(s.path||'')+'#'+(s.preset_b64||'').slice(0,16)).join('|');}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(){const chain=fxRtChain();const ck=fxRtChainKey(chain);if(fxRt.active){// masterBus bị rebuild (recovery/export) → node thuộc graph cũ → restart. +function fxRtChainKey(chain){return(chain||[]).map(s=>(s.type||'')+':'+(s.type==='vst3'?(s.path||'')+'#'+(s.preset_b64||'').slice(0,16):s.type||'')).join('|');}function fxRtChain(){const ms=window.currentMasteringSettings;if(!ms||!ms.masterConnected||ms.isBypassed)return[];// Chain hợp nhất (builtin + vst3 theo UI order) — mọi slot active gửi bridge. +return(ms.chain||[]).filter(s=>s&&s.active!==false&&(s.type!=='vst3'||!ms.vstBypass&&s.path&&!s.bypass));}// Param master chain entry → C++ BuiltinFx key (mirror Python _BUILTIN_PARAM_MAP). +const FX_RT_PARAM_MAP={eq:[['g1','eqLowGain'],['g2','eqMid1Gain'],['g3','eqMid2Gain'],['g4','eqHighGain']],compressor:[['threshold','compThreshold'],['ratio','compRatio'],['makeup','compMakeup']],limiter:[['ceiling','limThreshold']],exciter:[['drive','excDrive']],rebalance:[['mid','rebalMid'],['side','rebalSide']],imager:[['w1','w1'],['w2','w2'],['w3','w3'],['w4','w4']],maximizer:[['boost_db','maxGain'],['soft_clip','maxSoftClip'],['upward','maxUpward'],['ceiling_db','ceiling']]};function fxRtEntryParams(entry,ms){const out=[];if(!entry||entry.type==='vst3')return out;const src=ms||window.currentMasteringSettings||{};const push=(k,v)=>{const n=Number(v);if(isFinite(n))out.push({key:k,value:n});};const map=FX_RT_PARAM_MAP[entry.type]||[];map.forEach(([cppKey,jsKey])=>{if(entry.params&&entry.params[cppKey]!==undefined)push(cppKey,entry.params[cppKey]);else if(entry.params&&entry.params[jsKey]!==undefined)push(jsKey,entry.params[jsKey]);else if(src[jsKey]!==undefined)push(cppKey,src[jsKey]);});if(entry.type==='eqpro'){if(entry.params&&entry.params.amount!==undefined)push('amount',entry.params.amount);const bands=entry.params&&entry.params.bands||[];bands.forEach((b,i)=>{['freq','gain','q'].forEach(k=>{if(b&&b[k]!==undefined)push('band'+i+'_'+k,b[k]);});});}return out;}function fxRtPushAllParams(){if(!fxRt||!fxRt.ws||fxRt.ws.readyState!==1)return;const chain=fxRtChain();chain.forEach((s,i)=>{fxRtEntryParams(s,null).forEach(p=>{try{fxRt.ws.send(JSON.stringify({cmd:'set_param',slot:i,key:p.key,value:p.value}));}catch(e){}});});}async function fxRtStart(){const chain=fxRtChain();const ck=fxRtChainKey(chain);if(fxRt.active){// masterBus bị rebuild (recovery/export) → node thuộc graph cũ → restart. if(!fxRt.node||fxRt.node.context!==getAudioContext())fxRtStop(true);// Chain VST thay đổi (insert/remove/bypass/reorder trong MASTER FX CHAIN) // → stop + start lại để bridge load đúng chuỗi FX mới. -else if(fxRt.chainKey!==ck)fxRtStop(true);else return;}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;fxRtGuiPushShm();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ị +else if(fxRt.chainKey!==ck)fxRtStop(true);else return;}if(!chain.length)return;const ctx=getAudioContext();if(!ctx||!ctx.audioWorklet||!masterBus||!masterBus.output||!masterBus.analyser)return;const chainPayload=chain.map(s=>s.type==='vst3'?{type:'vst3',path:s.path,name:s.name||s.path,preset_b64:s.preset_b64||'',bypass:false}:{type:'builtin',id:s.type,params:s.params&&Object.keys(s.params).length?s.params:{},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;fxRtGuiPushShm();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;try{fxRtPushAllParams();}catch(e){}};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||!fxRt.ws)return;if(d.type==='set_param'){try{fxRt.ws.send(JSON.stringify({cmd:'set_param',slot:d.slot,key:d.key,value:d.value}));}catch(e){}return;}if(d.type!=='in')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){}// VU meter + peak graph (leftAnalyser/rightAnalyser qua splitter) đo âm -// SAU VST realtime: ngắt splitter khỏi output, nối vào fxRt.node để meter -// hiển thị audio data đã qua chuỗi VST (user requirement — peak/VU realtime). -if(masterBus.splitter){try{masterBus.output.disconnect(masterBus.splitter);}catch(e){}}masterBus.output.connect(fxRt.node);fxRt.node.connect(masterBus.analyser);if(masterBus.splitter)fxRt.node.connect(masterBus.splitter);fxRt.chainKey=ck;}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){}// Khôi phục VU meter splitter về output (pre-VST) khi tắt realtime. -if(masterBus.splitter){try{masterBus.output.connect(masterBus.splitter);}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;fxRtGuiPushShm();fxRt.active=false;fxRt.chainKey=null;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 ───── +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);// Phase 4.2: fader SAU worklet — worklet chèn giữa outputAnalyser (chain +// output) và output (fader/destination). output → analyser/splitter giữ +// nguyên (meter đo âm SAU fader). +try{masterBus.outputAnalyser.disconnect(masterBus.output);}catch(e){}masterBus.outputAnalyser.connect(fxRt.node);fxRt.node.connect(masterBus.output);fxRt.chainKey=ck;}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.outputAnalyser&&masterBus.output){try{masterBus.outputAnalyser.disconnect(masterBus.output);}catch(e){}try{masterBus.outputAnalyser.connect(masterBus.output);}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;fxRtGuiPushShm();fxRt.active=false;fxRt.chainKey=null;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 @@ -310,19 +283,14 @@ const fxRtTracks={};// 'trackId:audio|sf' → { sessionId, ws, node, ready, path // Embedded VST GUI → live SHM session feed (open_fx_gui shm + bridge /shm route). // url → trackId|null (null = master GUI). The GUI process peeks the session's // input ring so its meters move with real audio; push on every start/stop. -const _fxGuiEmbedSources={};function _fxGuiRegisterSource(url,trackId){if(!url)return;_fxGuiEmbedSources[url]=trackId===undefined?null:trackId;}function _fxGuiUnregisterSource(url){if(url)delete _fxGuiEmbedSources[url];}function fxRtTrackShmName(trackId){const key=trackId?Object.keys(fxRtTracks).find(k=>k.startsWith(trackId+':')):'';return key?fxRtTracks[key].sessionId||'':'';}function fxRtGuiPushShm(){Object.keys(_fxGuiEmbedSources).forEach(url=>{const trackId=_fxGuiEmbedSources[url];const name=trackId===null?fxRt.sessionId||'':fxRtTrackShmName(trackId);try{fetch(url+'/shm',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({shm:name})}).catch(()=>{});}catch(e){}});}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 _fxGuiEmbedSources={};function _fxGuiRegisterSource(url,trackId){if(!url)return;_fxGuiEmbedSources[url]=trackId===undefined?null:trackId;}function _fxGuiUnregisterSource(url){if(url)delete _fxGuiEmbedSources[url];}function fxRtTrackShmName(trackId){const key=trackId?Object.keys(fxRtTracks).find(k=>k.startsWith(trackId+':')):'';return key?fxRtTracks[key].sessionId||'':'';}function fxRtGuiPushShm(){Object.keys(_fxGuiEmbedSources).forEach(url=>{const trackId=_fxGuiEmbedSources[url];const name=trackId===null?fxRt.sessionId||'':fxRtTrackShmName(trackId);try{fetch(url+'/shm',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({shm:name})}).catch(()=>{});}catch(e){}});}function fxRtTrackChain(track){if(!track)return[];let chain=(track.fxChain||[]).filter(s=>s&&s.active!==false&&(s.type!=='vst3'||!track.vstFxBypass&&s.path&&!s.bypass));// Legacy: project cũ chỉ có vstFxChain riêng — append sau builtin. +if(!chain.some(s=>s&&s.type==='vst3')&&!track.vstFxBypass){chain=chain.concat(activeVstChain(track.vstFxChain||[]));}return chain;}function fxRtTrackChainKey(chain){return(chain||[]).map(s=>(s.type||'')+':'+(s.type==='vst3'?(s.path||'')+'#'+(s.preset_b64||'').slice(0,16):s.type||'')).join('|');}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). -function rebuildMasteringGraph(activate,chainArray){if(!masterBus)return;const active=!!activate;try{// 1. Disconnect all module boundary outputs (breaks static + previous dynamic links) -masterBus.inputAnalyser.disconnect();masterBus.eqHighFilter.disconnect();masterBus.imagerOutput.disconnect();masterBus.maximizerCompressor.disconnect();masterBus.compOutput.disconnect();masterBus.limOutput.disconnect();masterBus.excOutput.disconnect();masterBus.rebalOutput.disconnect();if(!active){// 2a. No mastering → straight chain: inputAnalyser → outputAnalyser -masterBus.inputAnalyser.connect(masterBus.outputAnalyser);masterBus.masteringActive=false;return;}// 2b. Filter active modules -const activeMods=(chainArray||[]).filter(m=>m&&m.active);if(activeMods.length===0){masterBus.inputAnalyser.connect(masterBus.outputAnalyser);masterBus.masteringActive=true;return;}// 3. Wire in series: Input → mod[0].input → mod[0].output → mod[1].input → … → Output -let prev=masterBus.inputAnalyser;// Dynamic modules (eqpro): rebuilt on every graph rebuild; instances kept -// in masterBus.eqProInstances so the Mastering panel can patch them live. -const eqProStore=masterBus.eqProInstances=masterBus.eqProInstances||{};Object.keys(eqProStore).forEach(k=>{try{eqProStore[k].destroy&&eqProStore[k].destroy();}catch(e){}});Object.keys(eqProStore).forEach(k=>delete eqProStore[k]);activeMods.forEach(mod=>{if(mod.type==='vst3'){// VST FX chạy NGOÀI (fx_vst_bridge) — không có node WebAudio trong -// master chain: pass-through, prev giữ nguyên. -return;}if(mod.type==='eqpro'){const m=createEqProModule(getAudioContext(),mod.params||{});eqProStore[mod.id]=m;prev.connect(m.input);prev=m.output;}else{const io=MASTER_MODULE_IO[mod.type]||MASTER_MODULE_IO.eq;const inNode=masterBus[io.input];if(inNode)prev.connect(inNode);prev=masterBus[io.output]||prev;}});prev.connect(masterBus.outputAnalyser);masterBus.masteringActive=true;}catch(e){console.warn('rebuildMasteringGraph error:',e);}}function toggleMasteringOnMaster(activate,isBypassed){if(!masterBus)return;const active=!!(activate&&!isBypassed);const chain=window.currentMasteringSettings&&window.currentMasteringSettings.chain||[];const cSig=chainSignature(chain);// Idempotent: don't rebuild unless active state OR chain layout changed. +function rebuildMasteringGraph(activate,chainArray){if(!masterBus)return;try{// Phase 4.2: master chain xử lý TOÀN bộ trong fx_vst_bridge — WebAudio +// master là pass-through (inputAnalyser → outputAnalyser). +masterBus.inputAnalyser.disconnect();masterBus.inputAnalyser.connect(masterBus.outputAnalyser);masterBus.masteringActive=!!activate;}catch(e){console.warn('rebuildMasteringGraph error:',e);}}function toggleMasteringOnMaster(activate,isBypassed){if(!masterBus)return;const active=!!(activate&&!isBypassed);const chain=window.currentMasteringSettings&&window.currentMasteringSettings.chain||[];const cSig=chainSignature(chain);// Idempotent: don't rebuild unless active state OR chain layout changed. if(_lastMasteringActive===active&&masterBus.masteringActive===active&&_lastChainSig===cSig)return;_lastMasteringActive=active;_lastChainSig=cSig;// Disconnect + immediately reconnect in one synchronous block so the master // routing can NEVER be left broken (a mid-swap exception would otherwise // disconnect inputAnalyser and silence ALL audio globally). @@ -735,10 +703,10 @@ if(t.clips&&t.clips.length>0){t.clips.forEach(c=>{const durationSec=c.buffer?c.b // referenced_section_id bị JSON.stringify bỏ → source_data = {} → // schema oneOf fail → lưu project báo lỗi xác thực (bug user gặp). // Item vô nghĩa (chưa có nội dung tab) — bỏ là đúng, không mất data. -if(!s.sectionId)return;items.push({id:s.id,name:s.name||"Section Item",type:"SECTION_ITEM",start_bar:s.start/secondsPerBar,duration_bars:s.duration/secondsPerBar,clip_start_offset_bars:0.0,source_data:{referenced_section_id:s.sectionId}});});}return{id:t.id,name:t.name,type:trackType,color:t.color||null,volume_db:t.volumeDb||0.0,pan:t.pan||0.0,mute:t.muted||false,solo:t.solo||false,mastering_bypass:t.audioBypass||false,audio_bypass:t.audioBypass||false,midi_bypass:t.midiBypass||false,fx_active:t.fxActive!==false,instrument_id:t.instrumentId||null,instrument_program:t.instrumentProgram!==undefined?t.instrumentProgram:null,instrument_name:t.instrumentName||null,instrument_source:t.instrument_source||(t.synth_engine?t.synth_engine.type:null),soundfont_id:t.soundfont_id||(t.synth_engine?t.synth_engine.soundfont_id:null),soundfont_bank:t.soundfont_bank!==undefined?t.soundfont_bank:t.synth_engine?t.synth_engine.soundfont_bank:null,soundfont_program:t.soundfont_program!==undefined?t.soundfont_program:t.synth_engine?t.synth_engine.soundfont_program:null,synth_engine:t.synth_engine||undefined,midi_channel:t.midiChannel!==undefined?t.midiChannel:null,fx_chain:(t.fxChain||[]).map(m=>typeof m==='string'?{type:m,active:true}:{type:m.type,active:m.active!==false}),vst_fx_chain:(t.vstFxChain||[]).map(s=>({type:s.type||'vst3',path:s.path||'',name:s.name||'',preset_b64:_capturedPresetByPath[s.path]||s.preset_b64||'',bypass:!!s.bypass})),vst_fx_bypass:!!t.vstFxBypass,vst_gui_open_path:t.id===_persistFxGuiTrackId&&_persistFxGuiOpen&&_persistFxGuiOpen.path?_persistFxGuiOpen.path:t.vstGuiOpenPath||null,vst_gui_open_name:t.id===_persistFxGuiTrackId&&_persistFxGuiOpen&&_persistFxGuiOpen.name?_persistFxGuiOpen.name:t.vstGuiOpenName||null,server_file_id:t.serverFileId||null,items:items};});};const deserializeTracksList=(schemaTracks,secondsPerBar,sectionStore,visitedSections,depth)=>{// Guard CHU TRÌNH: SECTION_ITEM trỏ về section đang được deserialize (hoặc +if(!s.sectionId)return;items.push({id:s.id,name:s.name||"Section Item",type:"SECTION_ITEM",start_bar:s.start/secondsPerBar,duration_bars:s.duration/secondsPerBar,clip_start_offset_bars:0.0,source_data:{referenced_section_id:s.sectionId}});});}return{id:t.id,name:t.name,type:trackType,color:t.color||null,volume_db:t.volumeDb||0.0,pan:t.pan||0.0,mute:t.muted||false,solo:t.solo||false,mastering_bypass:t.audioBypass||false,audio_bypass:t.audioBypass||false,midi_bypass:t.midiBypass||false,fx_active:t.fxActive!==false,instrument_id:t.instrumentId||null,instrument_program:t.instrumentProgram!==undefined?t.instrumentProgram:null,instrument_name:t.instrumentName||null,instrument_source:t.instrument_source||(t.synth_engine?t.synth_engine.type:null),soundfont_id:t.soundfont_id||(t.synth_engine?t.synth_engine.soundfont_id:null),soundfont_bank:t.soundfont_bank!==undefined?t.soundfont_bank:t.synth_engine?t.synth_engine.soundfont_bank:null,soundfont_program:t.soundfont_program!==undefined?t.soundfont_program:t.synth_engine?t.synth_engine.soundfont_program:null,synth_engine:t.synth_engine||undefined,midi_channel:t.midiChannel!==undefined?t.midiChannel:null,fx_chain:(t.fxChain||[]).map(m=>{if(typeof m==='string')return{type:m,active:true};const o={type:m.type,active:m.active!==false};if(m.type==='vst3'){o.path=m.path||'';o.name=m.name||'';o.preset_b64=_capturedPresetByPath[m.path]||m.preset_b64||'';o.bypass=!!m.bypass;}else if(m.params&&Object.keys(m.params).length)o.params=m.params;return o;}),vst_fx_chain:(()=>{const chainVst=(t.fxChain||[]).filter(m=>m&&m.type==='vst3');const vstSrc=chainVst.length?chainVst:t.vstFxChain||[];return vstSrc.map(s=>({type:'vst3',path:s.path||'',name:s.name||'',preset_b64:_capturedPresetByPath[s.path]||s.preset_b64||'',bypass:!!s.bypass}));})(),vst_fx_bypass:!!t.vstFxBypass,vst_gui_open_path:t.id===_persistFxGuiTrackId&&_persistFxGuiOpen&&_persistFxGuiOpen.path?_persistFxGuiOpen.path:t.vstGuiOpenPath||null,vst_gui_open_name:t.id===_persistFxGuiTrackId&&_persistFxGuiOpen&&_persistFxGuiOpen.name?_persistFxGuiOpen.name:t.vstGuiOpenName||null,server_file_id:t.serverFileId||null,items:items};});};const deserializeTracksList=(schemaTracks,secondsPerBar,sectionStore,visitedSections,depth)=>{// Guard CHU TRÌNH: SECTION_ITEM trỏ về section đang được deserialize (hoặc // chu trình A→B→A) → recursion vô hạn → RangeError: Maximum call stack size // exceeded khi restore project. Visited-set chặn cycle; depth chặn lồng sâu. -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 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:(()=>{const parsed=(t.fx_chain||[]).map(m=>typeof m==='string'?{type:m,active:true}:{type:m.type||m,active:m.active!==false,...(m.params?{params:m.params}:{}),...(m.type==='vst3'?{path:m.path||'',name:m.name||'',preset_b64:m.preset_b64||'',bypass:!!m.bypass}:{})});const seen=new Set(parsed.filter(m=>m.type==='vst3').map(m=>m.path));(t.vst_fx_chain||[]).forEach(s=>{if(s&&s.path&&!seen.has(s.path)){parsed.push({type:'vst3',path:s.path||'',name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass,active:true});seen.add(s.path);}});return parsed;})(),vstFxChain:(()=>{const fromChain=(t.fx_chain||[]).filter(m=>m&&m.type==='vst3');const src=fromChain.length?fromChain:t.vst_fx_chain||[];return src.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.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. @@ -748,7 +716,7 @@ _capturedPresetByPath={};const bpmVal=schemaObj.metadata?schemaObj.metadata.bpm: // meant S × (1 + v/100), which equals the new value (v + 100). Projects // saved after the migration carry `imagerScale: 'v2'` and are kept as-is. const _migrateMasteringSettings=ms=>{if(!ms)return null;const migrated=ms.imagerScale==='v2'?{...ms}:{...ms,w1:(ms.w1??0)+100,w2:(ms.w2??0)+100,w3:(ms.w3??0)+100,w4:(ms.w4??0)+100,imagerScale:'v2'};// mastering_expand.md: extension modules + dynamic chain (default = old chain order) -if(!Array.isArray(migrated.chain)){migrated.chain=[];}if(migrated.compActive===undefined)migrated.compActive=false;if(migrated.compThreshold===undefined)migrated.compThreshold=-16;if(migrated.compRatio===undefined)migrated.compRatio=3;if(migrated.compMakeup===undefined)migrated.compMakeup=0;if(migrated.limActive===undefined)migrated.limActive=false;if(migrated.limThreshold===undefined)migrated.limThreshold=-1.0;if(migrated.excActive===undefined)migrated.excActive=false;if(migrated.excDrive===undefined)migrated.excDrive=40;if(migrated.rebalActive===undefined)migrated.rebalActive=false;if(migrated.rebalMid===undefined)migrated.rebalMid=0;if(migrated.rebalSide===undefined)migrated.rebalSide=0;return migrated;};const _migratedMastering=_migrateMasteringSettings(schemaObj.mastering_settings)||{};const _masterBus=schemaObj.main_session&&schemaObj.main_session.master||{};const _masterFx=Array.isArray(_masterBus.fx_chain)?_masterBus.fx_chain:[];return{bpm:bpmVal,tracks:restoredTracks,sessionTabs:restoredSessionTabs,subTabs:restoredSubTabs,masteringSettings:{..._migratedMastering,vstFxChain:_masterFx.map(s=>({type:s.type||'vst3',path:s.path||'',name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass})),vstBypass:!!_masterBus.bypass,gui_open_path:schemaObj.mastering_settings&&schemaObj.mastering_settings.gui_open_path?schemaObj.mastering_settings.gui_open_path:null,gui_open_name:schemaObj.mastering_settings&&schemaObj.mastering_settings.gui_open_name?schemaObj.mastering_settings.gui_open_name:null},masterVolume:typeof _masterBus.volume_db==='number'&&isFinite(_masterBus.volume_db)?_masterBus.volume_db:0,zoom:schemaObj.metadata&&schemaObj.metadata.zoom?parseFloat(schemaObj.metadata.zoom):1.0};};// ────────────────────────────────────────────── +if(!Array.isArray(migrated.chain)){migrated.chain=[];}if(migrated.compActive===undefined)migrated.compActive=false;if(migrated.compThreshold===undefined)migrated.compThreshold=-16;if(migrated.compRatio===undefined)migrated.compRatio=3;if(migrated.compMakeup===undefined)migrated.compMakeup=0;if(migrated.limActive===undefined)migrated.limActive=false;if(migrated.limThreshold===undefined)migrated.limThreshold=-1.0;if(migrated.excActive===undefined)migrated.excActive=false;if(migrated.excDrive===undefined)migrated.excDrive=40;if(migrated.rebalActive===undefined)migrated.rebalActive=false;if(migrated.rebalMid===undefined)migrated.rebalMid=0;if(migrated.rebalSide===undefined)migrated.rebalSide=0;return migrated;};const _migratedMastering=_migrateMasteringSettings(schemaObj.mastering_settings)||{};const _masterBus=schemaObj.main_session&&schemaObj.main_session.master||{};const _masterFx=Array.isArray(_masterBus.fx_chain)?_masterBus.fx_chain:[];return{bpm:bpmVal,tracks:restoredTracks,sessionTabs:restoredSessionTabs,subTabs:restoredSubTabs,masteringSettings:(()=>{const migrated={..._migratedMastering};const _masterVstSources=[];(migrated.vstFxChain||[]).forEach(s=>{if(s&&s.path&&!_masterVstSources.some(x=>x.path===s.path))_masterVstSources.push(s);});_masterFx.forEach(s=>{if(s&&s.path&&!_masterVstSources.some(x=>x.path===s.path))_masterVstSources.push(s);});const _finalChain=[...(migrated.chain||[])];_masterVstSources.forEach(s=>{if(!_finalChain.some(m=>m.type==='vst3'&&m.path===s.path)){_finalChain.push({id:'mod_vst_'+Date.now()+'_'+Math.random().toString(36).slice(2,8),type:'vst3',path:s.path,name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass,active:true});}});return{...migrated,chain:_finalChain,vstFxChain:_finalChain.filter(m=>m.type==='vst3').map(s=>({type:'vst3',path:s.path||'',name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass})),vstBypass:!!_masterBus.bypass,gui_open_path:schemaObj.mastering_settings&&schemaObj.mastering_settings.gui_open_path?schemaObj.mastering_settings.gui_open_path:null,gui_open_name:schemaObj.mastering_settings&&schemaObj.mastering_settings.gui_open_name?schemaObj.mastering_settings.gui_open_name:null};})(),masterVolume:typeof _masterBus.volume_db==='number'&&isFinite(_masterBus.volume_db)?_masterBus.volume_db:0,zoom:schemaObj.metadata&&schemaObj.metadata.zoom?parseFloat(schemaObj.metadata.zoom):1.0};};// ────────────────────────────────────────────── // MASTERING KNOB COMPONENT (Dynamic pointer events version) // ────────────────────────────────────────────── const MasteringKnob=({param,min,max,value,unit,label,color,onChange,size='small'})=>{const[isDragging,setIsDragging]=React.useState(false);const startYRef=React.useRef(0);const startValRef=React.useRef(0);const handlePointerDown=e=>{e.preventDefault();setIsDragging(true);startYRef.current=e.clientY;startValRef.current=value;e.currentTarget.setPointerCapture(e.pointerId);};const handlePointerMove=e=>{if(!isDragging)return;const deltaY=startYRef.current-e.clientY;let newVal=startValRef.current+deltaY/150*(max-min);newVal=Math.min(max,Math.max(min,newVal));onChange(param,newVal);};const handlePointerUp=e=>{setIsDragging(false);try{e.currentTarget.releasePointerCapture(e.pointerId);}catch(err){}};const pct=(value-min)/(max-min);const angle=-135+pct*270;const isLarge=size==='large';const dialClass=isLarge?'w-20 h-20 border-4 bg-slate-900':'w-10 h-10 border-2 bg-slate-800';const pointerHeight=isLarge?'h-6':'h-3';const valClass=isLarge?'text-xs text-cyan-300 font-bold mt-2 z-10':'text-[9px] text-slate-300 font-mono mt-1 font-bold';return/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center select-none"},label&&/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-400 mb-1.5 uppercase tracking-wide"},label),/*#__PURE__*/React.createElement("div",{className:`${dialClass} rounded-full relative flex items-center justify-center cursor-ns-resize shadow-lg`,style:{borderColor:color},onPointerDown:handlePointerDown,onPointerMove:handlePointerMove,onPointerUp:handlePointerUp,onPointerCancel:handlePointerUp},/*#__PURE__*/React.createElement("div",{className:"w-0.5 absolute rounded origin-bottom",style:{backgroundColor:color,height:isLarge?'22px':'12px',top:isLarge?'6px':'4px',transform:`rotate(${angle}deg)`,transformOrigin:'50% 100%'}}),isLarge&&/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-cyan-300 z-10 bg-slate-950/80 px-1 py-0.5 rounded border border-slate-800"},value>0&&unit==='dB'?'+':'',value.toFixed(1)," ",unit)),!isLarge&&/*#__PURE__*/React.createElement("span",{className:valClass},value>0&&unit==='dB'?'+':'',value.toFixed(1),unit));};// ────────────────────────────────────────────── @@ -801,14 +769,14 @@ let _persistFxGuiTrackId=null;// Latest preset_b64 captured from each plugin's e // - merged into the project on save so knob tweaks survive reload. let _capturedPresetByPath={};const FXRackModal=({track,onUpdateTrack,onClose})=>{const[activeType,setActiveType]=React.useState('eq');const[addModuleOpen,setAddModuleOpen]=React.useState(false);const dragChainIndexRef=React.useRef(null);// Wave Observer scope state (unified_fx_rack_panel_update.md §III.3) 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([]);const[fxLoading,setFxLoading]=React.useState(false);React.useEffect(()=>{if(window.SonicAPI&&window.SonicAPI.listFx){setFxLoading(true);window.SonicAPI.listFx().then(d=>setFxList(d&&d.plugins||[])).catch(()=>setFxList([])).finally(()=>setFxLoading(false));}},[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});if(window.__rebuildTrackFxGraph)window.__rebuildTrackFxGraph(track.id,{vstFxChain:next,vstFxBypass:vstBypass});};// Embedded VST GUI state (PLAN_MASTERBUS_VST_GUI_EMBED.md Phase 3) +const[fxList,setFxList]=React.useState([]);const[fxLoading,setFxLoading]=React.useState(false);React.useEffect(()=>{if(window.SonicAPI&&window.SonicAPI.listFx){setFxLoading(true);window.SonicAPI.listFx().then(d=>setFxList(d&&d.plugins||[])).catch(()=>setFxList([])).finally(()=>setFxLoading(false));}},[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) — tham chiếu từ unified chain ── +const vstChain=chain.filter(m=>m&&m.type==='vst3');// 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:m&&m.type==='vst3'?'vst':'builtin',m,i}));const setVstChain=next=>{const builtin=chain.filter(m=>!m||m.type!=='vst3');const merged=[...builtin,...next.map(s=>({type:'vst3',path:s.path||'',name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass}))];if(onUpdateTrack)onUpdateTrack(track.id,{fxChain:merged,vstFxChain:next,vstFxBypass:vstBypass});if(window.__rebuildTrackFxGraph)window.__rebuildTrackFxGraph(track.id,{fxChain:merged,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=>{if(_persistFxGuiOpen&&_persistFxGuiOpen.url)_fxGuiUnregisterSource(_persistFxGuiOpen.url);_persistFxGuiOpen=v;_persistFxGuiTrackId=v?track&&track.id:null;if(v&&v.url)_fxGuiRegisterSource(v.url,track&&track.id);setFxGuiOpenState(v);};const fxGuiOpenRef=React.useRef(null);fxGuiOpenRef.current=fxGuiOpen;const closeFxGui=()=>{const g=fxGuiOpenRef.current;if(g){try{fetch(g.url+'/close',{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,shm:fxRtTrackShmName(track&&track.id)}).then(r=>{if(r&&r.embed_url)setFxGuiOpen({path:s.path,url:r.embed_url});}).catch(()=>{});// eslint-disable-next-line react-hooks/exhaustive-deps +React.useEffect(()=>{if(!track||!track.vstGuiOpenPath||fxGuiOpen)return;const s=(track.fxChain||[]).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,shm:fxRtTrackShmName(track&&track.id)}).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,shm:fxRtTrackShmName(track&&track.id)}).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&&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 ── @@ -822,7 +790,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," \u2014 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\u01B0a c\xF3 FX \u2014 b\u1EA5m [+] (DSP) ho\u1EB7c [+VST] \u0111\u1EC3 th\xEAm (signal \u0111i th\u1EB3ng: Source \u2192 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\xF3a 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\xF3a 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\xEAm 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\xEAm 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 \u2014 realtime qua bridge (SHM) khi play + \xE1p d\u1EE5ng l\xFAc 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\u1EED (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\u01B0a c\xF3 module. B\u1EA5m [+] \u0111\u1EC3 th\xEAm 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 \u2014 EQ RESPONSE"),/*#__PURE__*/React.createElement("span",null,"20Hz \u2013 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 \u2014 Pro-Q style interactive"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500 font-mono"},"20Hz \u2013 20kHz \xB7 \xB124dB \xB7 ",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 \xB7 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 \xB7 4\xD7 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`)))),/*#__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\xCAM MODULE V\xC0O 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,loading:fxLoading,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," \u2014 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\u01B0a c\xF3 FX \u2014 b\u1EA5m [+] (DSP) ho\u1EB7c [+VST] \u0111\u1EC3 th\xEAm (signal \u0111i th\u1EB3ng: Source \u2192 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.m);setChain(nb);setVstChain(nv);if(mv.kind==='vst')setActiveVstIdx(nv.indexOf(mv.m));}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\xF3a module"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})));}const s=u.m;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.m);setChain(nb);setVstChain(nv);if(mv.kind==='vst')setActiveVstIdx(nv.indexOf(mv.m));}dragChainIndexRef.current=null;},onClick:()=>{closeFxGui();setActiveVstIdx(vstChain.indexOf(s));},className:`w-36 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all shrink-0 ${activeVstIdx===vstChain.indexOf(s)?'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(vstChain.indexOf(s));},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(vstChain.indexOf(s));},className:"text-slate-600 hover:text-red-400 text-xs px-0.5 shrink-0",title:"X\xF3a 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\xEAm 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\xEAm 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 \u2014 realtime qua bridge (SHM) khi play + \xE1p d\u1EE5ng l\xFAc 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\u1EED (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\u01B0a c\xF3 module. B\u1EA5m [+] \u0111\u1EC3 th\xEAm 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 \u2014 EQ RESPONSE"),/*#__PURE__*/React.createElement("span",null,"20Hz \u2013 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 \u2014 Pro-Q style interactive"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500 font-mono"},"20Hz \u2013 20kHz \xB7 \xB124dB \xB7 ",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 \xB7 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 \xB7 4\xD7 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`)))),/*#__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\xCAM MODULE V\xC0O 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,loading:fxLoading,onPick:f=>addVst(f.path)}));};// ────────────────────────────────────────────── // MASTERING MODAL COMPONENT (from md/45_MASTERING_MODULE.md) // ────────────────────────────────────────────── // GUI embed state survives MasteringModal unmount so the native window @@ -834,7 +802,7 @@ const setMasterGuiOpen=v=>{if(_persistMasterGuiOpen&&_persistMasterGuiOpen.url)_ React.useEffect(()=>{if(!masterGuiOpen||!masterGuiOpen.url)return;const iv=setInterval(()=>{fetch(masterGuiOpen.url+'/preset').then(r=>r.json()).then(d=>{if(d&&d.preset_b64)_capturedPresetByPath[masterGuiOpen.path]=d.preset_b64;}).catch(()=>{});},1500);return()=>clearInterval(iv);// eslint-disable-next-line react-hooks/exhaustive-deps },[masterGuiOpen]);// Reopen the saved GUI when the panel is opened after a project restore. React.useEffect(()=>{if(!isOpen||!ozState||!ozState.gui_open_path||masterGuiOpen)return;const s=(ozState.vstFxChain||[]).find(x=>x.path===ozState.gui_open_path);if(!s||!window.SonicAPI||!window.SonicAPI.openFxGui)return;window.SonicAPI.openFxGui({path:s.path,name:ozState.gui_open_name||s.name||s.path,embed:true,shm:fxRt.sessionId||''}).then(r=>{if(r&&r.embed_url)setMasterGuiOpen({path:s.path,url:r.embed_url});}).catch(()=>{});// eslint-disable-next-line react-hooks/exhaustive-deps -},[isOpen,ozState&&ozState.gui_open_path]);const setMasterVstChain=next=>setOzState(prev=>({...prev,vstFxChain:next}));// LIVE master FX xử lý bởi fxRtStart() (SHM loop qua fx_vst_bridge +},[isOpen,ozState&&ozState.gui_open_path]);const setMasterVstChain=next=>setOzState(prev=>{const chain=prev.chain||[];const out=[];let vi=0;const mk=(s,i)=>({id:'mod_vst_'+Date.now()+'_'+i+'_'+Math.random().toString(36).slice(2,8),type:'vst3',path:s.path,name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass,active:true});chain.forEach(m=>{if(m.type==='vst3'){const s=next[vi];if(s){out.push(mk(s,vi));vi++;}}else out.push(m);});for(;vi{window.removeEventListener('resize',resizeAll);if(animFrameRef.current)cancelAnimationFrame(animFrameRef.current);};},[isOpen]);React.useEffect(()=>{if(!isOpen){stopAudioDemo();knobsInitializedRef.current=false;}else{setTimeout(()=>{try{window.lucide.createIcons();}catch(e){}},100);}},[isOpen]);// Hooks MUST be declared before the early return below — React requires a // stable hook count across renders (error #310 otherwise). const dragChainIndexRef=React.useRef(null);const[addModuleOpen,setAddModuleOpen]=React.useState(false);if(!isOpen)return null;const switchModule=name=>setOzState(prev=>({...prev,activeModule:name}));// ── Mastering expand: dynamic module chain (mastering_expand.md §II.3) ── -const MODULE_META={eq:{name:'Dynamic EQ',sub:'4-Band Peak',icon:'activity',color:'#22d3ee'},eqpro:{name:'Parametric / Graphic EQ PRO',sub:'Pro-Q style · 8 bands · interactive',icon:'chart-area',color:'#2dd4bf'},imager:{name:'Imager',sub:'4-Band Width',icon:'radio',color:'#a855f7'},maximizer:{name:'Maximizer',sub:'IRC IV True Peak',icon:'gauge',color:'#34d399'},compressor:{name:'Bus Compressor',sub:'Glue & Punch',icon:'compress',color:'#fbbf24'},limiter:{name:'Brickwall Limiter',sub:'True-Peak 20:1',icon:'shield-half',color:'#f43f5e'},exciter:{name:'Harmonic Exciter',sub:'Saturation & Air',icon:'wand-2',color:'#c084fc'},rebalance:{name:'Master Rebalance',sub:'M/S Balance',icon:'sliders-horizontal',color:'#38bdf8'},vst3:{name:'VST FX',sub:'VST3 plugin',icon:'plug',color:'#2dd4bf'}};const chainFlag=type=>type==='eq'?'eqActive':type==='eqpro'?'eqproActive':type==='imager'?'imagerActive':type==='maximizer'?'maximizerActive':type==='compressor'?'compActive':type==='limiter'?'limActive':type==='exciter'?'excActive':type==='vst3'?'vstFxActive':'rebalActive';const chainActive=type=>!!ozState[chainFlag(type)];const toggleChainModule=modId=>{setOzState(prev=>{const chain=prev.chain.map(m=>{if(m.id!==modId)return m;const next=!m.active;return{...m,active:next};});const flags={};chain.forEach(m=>{flags[chainFlag(m.type)]=!!m.active;});const target=chain.find(m=>m.id===modId);let vstFxChain=prev.vstFxChain||[];if(target&&target.type==='vst3'){const vi=vstFxChain.findIndex(s=>s.path===target.path);if(vi>=0)vstFxChain=vstFxChain.map((s,si)=>si===vi?{...s,bypass:!target.active}:s);}return{...prev,chain,...flags,vstFxChain};});};const removeChainModule=modId=>{const removedMod=(ozState.chain||[]).find(m=>m.id===modId);if(removedMod&&removedMod.type==='vst3'&&masterGuiOpen&&masterGuiOpen.path===removedMod.path)closeMasterGui();setOzState(prev=>{const removed=prev.chain.find(m=>m.id===modId);const chain=prev.chain.filter(m=>m.id!==modId);const flags={};chain.forEach(m=>{flags[chainFlag(m.type)]=!!m.active;});const activeModule=prev.activeModule;let vstFxChain=prev.vstFxChain||[];if(removed&&removed.type==='vst3'){const vi=vstFxChain.findIndex(s=>s.path===removed.path);if(vi>=0)vstFxChain=[...vstFxChain.slice(0,vi),...vstFxChain.slice(vi+1)];}return{...prev,chain,...flags,vstFxChain,activeModule:chain.length?chain.some(m=>m.type===activeModule)?activeModule:chain[chain.length-1].type:null};});};const reorderChain=(fromIdx,toIdx)=>{if(fromIdx===toIdx)return;setOzState(prev=>{const chain=[...prev.chain];const moved=chain.splice(fromIdx,1)[0];chain.splice(toIdx,0,moved);return{...prev,chain};});};const addModuleToChain=type=>{const meta=MODULE_META[type];const id='mod_'+type+'_'+Date.now();const entry={id,type,name:meta.name,active:true};if(type==='eqpro')entry.params={amount:100,bands:JSON.parse(JSON.stringify(EQPRO_DEFAULT_BANDS))};setOzState(prev=>({...prev,chain:[...(prev.chain||[]),entry],[chainFlag(type)]:true,activeModule:type}));setAddModuleOpen(false);};const addVstToChain=path=>{const fx=(masterFxList||[]).find(f=>f.path===path);if(!fx)return;const id='mod_vst_'+Date.now();const entry={id,type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false,active:true};setOzState(prev=>({...prev,chain:[...(prev.chain||[]),entry],vstFxChain:[...(prev.vstFxChain||[]),{type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false}]}));setAddModuleOpen(false);};const updateChainEntryParams=(modId,patch)=>{setOzState(prev=>({...prev,chain:(prev.chain||[]).map(m=>m.id===modId?{...m,params:{...(m.params||{}),...patch}}:m)}));};// ── EQ Preset Library (mastering_expand.md §II.1) ── -const EQ_PRESET_LIBRARY={flat:{name:'Flat / Reset',bands:[{id:1,type:'lowshelf',freq:100,gain:0.0,q:0.7},{id:2,type:'peaking',freq:800,gain:0.0,q:0.7},{id:3,type:'peaking',freq:3200,gain:0.0,q:1.2},{id:4,type:'highshelf',freq:10000,gain:0.0,q:0.7}]},vocal_clarity:{name:'Vocal Unmask & Clarity',bands:[{id:1,type:'lowshelf',freq:90,gain:-2.5,q:0.7},{id:2,type:'peaking',freq:500,gain:-1.8,q:1.0},{id:3,type:'peaking',freq:2800,gain:3.2,q:1.2},{id:4,type:'highshelf',freq:12000,gain:2.0,q:0.7}]},bass_punch:{name:'EDM Low-End Punch',bands:[{id:1,type:'lowshelf',freq:80,gain:4.0,q:0.8},{id:2,type:'peaking',freq:300,gain:-3.0,q:1.4},{id:3,type:'peaking',freq:4000,gain:1.5,q:1.0},{id:4,type:'highshelf',freq:10000,gain:1.0,q:0.7}]},warm_tape:{name:'Warm Vintage Analog',bands:[{id:1,type:'lowshelf',freq:120,gain:2.0,q:0.6},{id:2,type:'peaking',freq:1500,gain:1.0,q:0.5},{id:3,type:'peaking',freq:5000,gain:-2.0,q:1.0},{id:4,type:'highshelf',freq:8000,gain:-3.0,q:0.7}]}};const applyEQPreset=presetKey=>{const preset=EQ_PRESET_LIBRARY[presetKey];if(!preset)return;const bus=masterBus;if(bus&&bus.eqLowFilter){const now=audioCtx?audioCtx.currentTime:0;const filters=[bus.eqLowFilter,bus.eqMid1Filter,bus.eqMid2Filter,bus.eqHighFilter];preset.bands.forEach((bd,i)=>{const f=filters[i];if(!f)return;try{// setValueAtTime (không automation) — tránh "BiquadFilterNode: state is bad" -f.frequency.setValueAtTime(bd.freq,now);f.gain.setValueAtTime(bd.gain,now);f.Q.setValueAtTime(bd.q,now);}catch(e){}});}// Sync UI knobs + canvas: [eqLowGain, eqMid1Gain, eqMid2Gain, eqHighGain] -setOzState(prev=>({...prev,eqLowGain:preset.bands[0]?preset.bands[0].gain:prev.eqLowGain,eqMid1Gain:preset.bands[1]?preset.bands[1].gain:prev.eqMid1Gain,eqMid2Gain:preset.bands[2]?preset.bands[2].gain:prev.eqMid2Gain,eqHighGain:preset.bands[3]?preset.bands[3].gain:prev.eqHighGain,eqPreset:presetKey}));};const bandKnob=(param,min,max,val,unit,label,freq,color,filterType)=>/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-2 flex flex-col justify-between items-center rounded-lg"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[10px] font-bold w-full",style:{color}},/*#__PURE__*/React.createElement("span",null,label),/*#__PURE__*/React.createElement("span",{className:"text-[9px] font-mono text-slate-400"},freq)),/*#__PURE__*/React.createElement("div",{className:"my-1"},/*#__PURE__*/React.createElement(MasteringKnob,{param:param,min:min,max:max,value:ozState[param]!==undefined?ozState[param]:val,unit:unit,color:color,onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 font-mono text-center mt-1"},filterType));return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm",onClick:onClose},/*#__PURE__*/React.createElement("div",{id:"masteringModalBody",className:"flex flex-col bg-slate-950 text-slate-200 w-[95vw] h-[92vh] max-w-[1400px] rounded-2xl border border-slate-800/80 shadow-2xl overflow-hidden",onClick:e=>e.stopPropagation(),style:{fontFamily:"'Inter', sans-serif"}},/*#__PURE__*/React.createElement("header",{className:"h-12 bg-slate-900/90 border-b border-slate-800/80 flex items-center justify-between px-4 z-30 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3"},/*#__PURE__*/React.createElement("div",{className:"w-7 h-7 rounded-lg bg-cyan-600 flex items-center justify-center font-bold text-white shadow-lg shadow-cyan-950"},/*#__PURE__*/React.createElement("i",{"data-lucide":"zap",className:"w-3.5 h-3.5"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("h1",{className:"text-xs font-bold tracking-wider text-white flex items-center gap-2"},"MASTER FX CHAIN ",/*#__PURE__*/React.createElement("span",{className:"text-[9px] bg-cyan-950 text-cyan-400 border border-cyan-800/80 px-1.5 py-0.5 rounded oz-font-mono"},"WEB MASTERING V10.5")))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3 bg-slate-950 px-3 py-1 rounded-lg border border-slate-800/80"},/*#__PURE__*/React.createElement("button",{onClick:startAudioDemo,className:"px-3 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1.5 transition-all shadow-md shadow-cyan-950"},/*#__PURE__*/React.createElement("i",{"data-lucide":"play",className:"w-3 h-3"})," ",/*#__PURE__*/React.createElement("span",null,"Play Reference")),/*#__PURE__*/React.createElement("button",{onClick:stopAudioDemo,className:"px-3 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs font-semibold flex items-center gap-1.5 transition-all"},/*#__PURE__*/React.createElement("i",{"data-lucide":"square",className:"w-3 h-3"})," ",/*#__PURE__*/React.createElement("span",null,"Stop")),/*#__PURE__*/React.createElement("div",{className:"h-4 w-[1px] bg-slate-800 mx-1"}),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 text-xs oz-font-mono"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400 text-[11px]"},"Preset:"),/*#__PURE__*/React.createElement("select",{className:"bg-slate-900 border border-slate-700 rounded px-2 py-0.5 text-xs text-cyan-300 outline-none focus:border-cyan-500"},/*#__PURE__*/React.createElement("option",{value:"adaptive"},"Adaptive Dynamic Master"),/*#__PURE__*/React.createElement("option",{value:"edm_club"},"EDM / Club Punch Maximizer"),/*#__PURE__*/React.createElement("option",{value:"wide_space"},"Cinematic Stereo Expansion"),/*#__PURE__*/React.createElement("option",{value:"transparent"},"Transparent High-Clarity Limiter")))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3 text-xs oz-font-mono"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400 text-[11px]"},"Target LUFS:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold bg-slate-950 border border-cyan-900/60 px-2 py-0.5 rounded"},"-11.0 LUFS"),/*#__PURE__*/React.createElement("button",{onClick:()=>{getAudioContext();setMasterConnected(prev=>!prev);},className:`px-2 py-0.5 rounded text-[10px] font-bold border transition-colors ${masterConnected?'bg-emerald-700 border-emerald-500 text-white':'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'}`},masterConnected?'Master ON':'Master OFF'),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"px-2 py-0.5 rounded text-[10px] font-bold border transition-colors bg-slate-800 border-slate-700 text-slate-300 hover:bg-[#c2410c] hover:border-red-600 hover:text-white"},"\u0110\xF3ng"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-500 hover:text-slate-300 ml-1"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"})))),/*#__PURE__*/React.createElement("div",{className:"h-16 bg-slate-950 border-b border-slate-800 px-4 flex items-center gap-2 overflow-x-auto shrink-0 oz-scrollbar"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-500 uppercase tracking-widest mr-1 oz-font-mono shrink-0"},"CHAIN:"),(ozState.chain||[]).map((mod,idx)=>{const isVst=mod.type==='vst3';const meta=MODULE_META[mod.type]||{name:mod.type,sub:'',icon:'circle',color:'#94a3b8'};const isEditing=!isVst&&ozState.activeModule===mod.type||isVst&&activeMasterVstPath===mod.path;const isOn=isVst?mod.active!==false:chainActive(mod.type);return/*#__PURE__*/React.createElement("div",{key:mod.id,draggable:true,onDragStart:e=>{dragChainIndexRef.current=idx;e.dataTransfer.effectAllowed='move';},onDragOver:e=>{e.preventDefault();e.dataTransfer.dropEffect='move';},onDrop:e=>{e.preventDefault();const from=dragChainIndexRef.current;if(from!==null&&from!==idx)reorderChain(from,idx);dragChainIndexRef.current=null;},onClick:()=>{if(mod.type==='vst3'){openMasterVstGui({path:mod.path,name:mod.name||mod.path});}else{closeMasterGui();switchModule(mod.type);}},className:`w-40 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all shrink-0 ${isEditing?'oz-card-active':'oz-card'}`},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 min-w-0"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleChainModule(mod.id);},className:"w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0",style:{backgroundColor:isOn?'#38bdf8':'#334155',color:isOn?'#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"},isVst?mod.name||meta.name:meta.name),/*#__PURE__*/React.createElement("div",{className:"text-[9px] oz-font-mono truncate",style:{color:meta.color}},idx+1,". ",isVst?mod.path?mod.path.split(/[\\/]/).pop():'VST3':meta.sub))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":meta.icon,className:"w-3 h-3 text-slate-500"}),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();removeChainModule(mod.id);},className:"text-slate-600 hover:text-red-400 text-xs w-4 h-4 flex items-center justify-center shrink-0",title:"X\xF3a module"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"}))));}),/*#__PURE__*/React.createElement("button",{onClick:()=>setAddModuleOpen(true),className:"w-24 h-12 rounded-lg border border-dashed border-slate-800 flex items-center justify-center text-slate-600 hover:text-slate-400 hover:border-slate-600 cursor-pointer transition-all shrink-0",title:"Th\xEAm module v\xE0o chain"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("main",{className:"flex-1 flex overflow-hidden min-h-0"},masterGuiOpen?/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col bg-slate-950 relative min-h-0"},/*#__PURE__*/React.createElement(VstGuiEmbed,{url:masterGuiOpen.url,onClose:closeMasterGui,onDead:()=>{window.showToast&&window.showToast('Bridge VST GUI đã mất kết nối — trả về graph','error');closeMasterGui();}})):/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col bg-slate-950 relative overflow-y-auto oz-scrollbar"},/*#__PURE__*/React.createElement("div",{className:"h-9 bg-slate-900/60 border-b border-slate-800/80 px-4 flex items-center justify-between text-xs oz-font-mono shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400 flex items-center gap-1.5"},/*#__PURE__*/React.createElement("i",{"data-lucide":"headphones",className:"w-3 h-3 text-cyan-400"})," Delta Listen"),/*#__PURE__*/React.createElement("select",{className:"bg-slate-950 border border-slate-800 rounded px-2 py-0.5 text-[11px] text-slate-300 outline-none"},/*#__PURE__*/React.createElement("option",{value:"irc4"},"IRC IV - Classic"),/*#__PURE__*/React.createElement("option",{value:"irc3"},"IRC III - Balanced"),/*#__PURE__*/React.createElement("option",{value:"irc2"},"IRC II - Crisp"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 text-[11px]"},ozState.activeModule==='eq'&&/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"EQ Presets:"),/*#__PURE__*/React.createElement("select",{value:ozState.eqPreset||'flat',onChange:e=>applyEQPreset(e.target.value),className:"bg-slate-950 border border-slate-700 text-cyan-300 rounded px-2 py-0.5 text-[11px] outline-none focus:border-cyan-500"},Object.keys(EQ_PRESET_LIBRARY).map(k=>/*#__PURE__*/React.createElement("option",{key:k,value:k},EQ_PRESET_LIBRARY[k].name)))),/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"Learn Input Gain:"),/*#__PURE__*/React.createElement("button",{className:"bg-cyan-950 text-cyan-300 border border-cyan-800/80 px-2 py-0.5 rounded font-bold hover:bg-cyan-900 transition-colors"},"-11.0 LUFS"))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='eq'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"relative w-full h-64 bg-slate-950 border border-slate-800/90 rounded-xl overflow-hidden shadow-inner cursor-crosshair"},/*#__PURE__*/React.createElement("canvas",{ref:eqCanvasRef,className:"w-full h-full block"})),/*#__PURE__*/React.createElement("div",{className:"oz-panel p-3 rounded-xl grid grid-cols-4 gap-3"},bandKnob('eqLowGain',-12,12,1.5,'dB','BAND 1 (LOW)','100 Hz','#22d3ee','Shelf Filter'),bandKnob('eqMid1Gain',-12,12,-1.0,'dB','BAND 2 (MID LOW)','822 Hz','#fbbf24','Dynamic Bell (Q: 0.7)'),bandKnob('eqMid2Gain',-12,12,2.0,'dB','BAND 3 (MID HIGH)','3.2 kHz','#a855f7','Dynamic Bell (Q: 1.2)'),bandKnob('eqHighGain',-12,12,1.8,'dB','BAND 4 (HIGH)','10 kHz','#34d399','High Shelf'))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='eqpro'?'':'hidden'}`},(()=>{const chainMods=ozState.chain||[];const cm=chainMods.find(m=>m.type==='eqpro'&&m.active!==false)||chainMods.find(m=>m.type==='eqpro');if(!cm)return/*#__PURE__*/React.createElement("div",{className:"text-xs text-slate-500 font-mono py-10 text-center"},"Ch\u01B0a c\xF3 module EQ PRO trong chain \u2014 b\u1EA5m [+] \u0111\u1EC3 th\xEAm.");return/*#__PURE__*/React.createElement(InteractiveEqPro,{track:null,params:cm.params||{},onChange:next=>updateChainEntryParams(cm.id,next),getModule:()=>masterBus&&masterBus.eqProInstances?masterBus.eqProInstances[cm.id]:null,spectrumModules:()=>{const m=masterBus&&masterBus.eqProInstances?masterBus.eqProInstances[cm.id]:null;return m?[m]:[];},applyTo:fn=>{const m=masterBus&&masterBus.eqProInstances?masterBus.eqProInstances[cm.id]:null;if(m)fn(m);}});})()),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='imager'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-12 gap-4 flex-1"},/*#__PURE__*/React.createElement("div",{className:"col-span-8 bg-slate-950 border border-slate-800/90 rounded-xl p-3 flex flex-col relative shadow-inner"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-cyan-400 uppercase oz-font-mono mb-2 flex items-center justify-between w-full"},/*#__PURE__*/React.createElement("span",null,/*#__PURE__*/React.createElement("i",{"data-lucide":"radio",className:"w-3.5 h-3.5"})," Polar Vectorscope & Correlation Meter"),/*#__PURE__*/React.createElement("span",{className:"text-[11px] text-slate-400 normal-case"},"Corr: ",/*#__PURE__*/React.createElement("span",{id:"corrText",className:"font-bold text-emerald-400"},"1.00"))),/*#__PURE__*/React.createElement("div",{className:"flex-1 relative w-full h-56 flex items-center justify-center"},/*#__PURE__*/React.createElement("canvas",{ref:imagerCanvasRef,className:"w-full h-full block"}))),/*#__PURE__*/React.createElement("div",{className:"col-span-4 oz-panel p-3 rounded-xl flex flex-col justify-between"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-slate-300 uppercase oz-font-mono"},"4-Band Stereo Width"),/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-slate-500 oz-font-mono leading-snug"},"0% = Mono \xB7 100% = Original \xB7 200% = 2\xD7 Width"),/*#__PURE__*/React.createElement("div",{className:"space-y-3 my-auto"},[{id:'w1',label:'Band 1 (20-100Hz)',color:'#22d3ee',val:ozState.w1},{id:'w2',label:'Band 2 (100Hz-1kHz)',color:'#fbbf24',val:ozState.w2},{id:'w3',label:'Band 3 (1k-6kHz)',color:'#a855f7',val:ozState.w3},{id:'w4',label:'Band 4 (6k-20kHz)',color:'#34d399',val:ozState.w4}].map(b=>/*#__PURE__*/React.createElement("div",{key:b.id},/*#__PURE__*/React.createElement("div",{className:"flex justify-between text-[11px] font-mono mb-1"},/*#__PURE__*/React.createElement("span",{style:{color:b.color,fontWeight:700}},b.label),/*#__PURE__*/React.createElement("span",{id:b.id+'Val'},b.val,"%")),/*#__PURE__*/React.createElement("input",{type:"range",min:"0",max:"200",value:b.val,onChange:e=>setOzState(prev=>({...prev,[b.id]:parseInt(e.target.value)})),className:"w-full h-1 cursor-pointer",style:{accentColor:b.color}}))))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='maximizer'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-slate-400 uppercase oz-font-mono mb-3"},"Maximizer Gain Boost"),/*#__PURE__*/React.createElement("div",{className:"my-2"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxGain",min:0,max:12,value:ozState.maxGain,unit:"dB",color:"#22d3ee",size:"large",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"w-full mt-4 bg-slate-950 p-2 rounded-lg border border-slate-800 text-xs oz-font-mono flex flex-col gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center w-full"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"Ceiling Level:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold"},ozState.ceiling.toFixed(2)," dB")),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"0",step:"0.1",value:ozState.ceiling,onChange:e=>setOzState(prev=>({...prev,ceiling:parseFloat(e.target.value)})),className:"w-full h-1 cursor-pointer accent-cyan-400"}))),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxUpward",min:0,max:10,value:ozState.maxUpward,unit:"dB",label:"UPWARD COMPRESS",color:"#22d3ee",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxSoftClip",min:0,max:100,value:ozState.maxSoftClip,unit:"%",label:"SOFT CLIPPER",color:"#fbbf24",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxTransient",min:0,max:100,value:ozState.maxTransient,unit:"%",label:"TRANSIENT EMPHASIS",color:"#34d399",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))}))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='compressor'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-amber-400 uppercase oz-font-mono mb-3"},"Bus Compressor"),/*#__PURE__*/React.createElement("div",{className:"my-2"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"compMakeup",min:0,max:12,value:ozState.compMakeup,unit:"dB",label:"MAKE-UP GAIN",color:"#fbbf24",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("button",{onClick:()=>toggleChainModule((ozState.chain||[]).find(m=>m.type==='compressor')?.id),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.compActive?'bg-amber-700 border-amber-500 text-white':'bg-slate-800 border-slate-700 text-slate-300'}`},ozState.compActive?'ON':'OFF')),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"compThreshold",min:-60,max:0,value:ozState.compThreshold,unit:"dB",label:"THRESHOLD",color:"#fbbf24",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"compRatio",min:1,max:20,value:ozState.compRatio,unit:":1",label:"RATIO",color:"#f59e0b",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2"},"Glue & Punch",/*#__PURE__*/React.createElement("br",null),"Attack 20ms \xB7 Release 250ms \xB7 Knee 8dB"))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='limiter'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-rose-400 uppercase oz-font-mono mb-3"},"Brickwall Limiter"),/*#__PURE__*/React.createElement("button",{onClick:()=>toggleChainModule((ozState.chain||[]).find(m=>m.type==='limiter')?.id),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.limActive?'bg-rose-700 border-rose-500 text-white':'bg-slate-800 border-slate-700 text-slate-300'}`},ozState.limActive?'ON':'OFF')),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"limThreshold",min:-24,max:0,value:ozState.limThreshold,unit:"dB",label:"CEILING",color:"#f43f5e",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2"},"True-Peak limiting",/*#__PURE__*/React.createElement("br",null),"Ratio 20:1 \xB7 Knee 0dB",/*#__PURE__*/React.createElement("br",null),"Attack 1ms \xB7 Release 50ms"))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='exciter'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-purple-400 uppercase oz-font-mono mb-3"},"Harmonic Exciter"),/*#__PURE__*/React.createElement("button",{onClick:()=>toggleChainModule((ozState.chain||[]).find(m=>m.type==='exciter')?.id),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.excActive?'bg-purple-700 border-purple-500 text-white':'bg-slate-800 border-slate-700 text-slate-300'}`},ozState.excActive?'ON':'OFF')),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"excDrive",min:0,max:100,value:ozState.excDrive,unit:"%",label:"DRIVE / MIX",color:"#c084fc",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2"},"WaveShaper saturation",/*#__PURE__*/React.createElement("br",null),"High-pass 2kHz",/*#__PURE__*/React.createElement("br",null),"4\xD7 oversampled \xB7 wet/dry mix"))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='rebalance'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-sky-400 uppercase oz-font-mono mb-3"},"Master Rebalance (M/S)"),/*#__PURE__*/React.createElement("button",{onClick:()=>toggleChainModule((ozState.chain||[]).find(m=>m.type==='rebalance')?.id),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.rebalActive?'bg-sky-700 border-sky-500 text-white':'bg-slate-800 border-slate-700 text-slate-300'}`},ozState.rebalActive?'ON':'OFF')),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"rebalMid",min:-12,max:12,value:ozState.rebalMid,unit:"dB",label:"MID GAIN",color:"#38bdf8",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"rebalSide",min:-12,max:12,value:ozState.rebalSide,unit:"dB",label:"SIDE GAIN",color:"#22d3ee",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2"},"ChannelSplitter + M/S gains",/*#__PURE__*/React.createElement("br",null),"Center (vocal/bass) vs Sides (stereo width)"))))),/*#__PURE__*/React.createElement("div",{className:"border-t border-slate-800 bg-slate-900/60 p-3 flex flex-col shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between pb-1.5 border-b border-slate-800/80 mb-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-[11px] font-bold text-white uppercase tracking-wider"},"Wave Observer"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] bg-cyan-950 text-cyan-400 border border-cyan-800/60 px-1.5 py-0.2 rounded font-mono"},"Real-time Oscilloscope")),/*#__PURE__*/React.createElement("div",{className:"flex gap-2"},['Scope','Settings','Help','About'].map(tab=>/*#__PURE__*/React.createElement("button",{key:tab,className:`px-2 py-0.5 rounded text-[10px] font-bold ${tab==='Scope'?'bg-cyan-950 text-cyan-300 border border-cyan-800/60':'text-slate-400 hover:text-slate-200'}`},tab)))),/*#__PURE__*/React.createElement("div",{className:"relative w-full h-32 bg-[#090d16] border border-slate-800/80 rounded-lg overflow-hidden mb-2"},/*#__PURE__*/React.createElement("canvas",{ref:woCanvasRef,className:"w-full h-full block"})),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between gap-4 text-[10px] oz-font-mono text-slate-400 mt-1 select-none"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 bg-slate-950 px-2.5 py-1.5 rounded border border-slate-800/80 shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-300"},"Input"),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1 w-20"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-[8px] text-slate-500 w-2"},"L"),/*#__PURE__*/React.createElement("div",{className:"w-16 h-1.5 bg-slate-900 rounded-sm overflow-hidden flex items-center"},/*#__PURE__*/React.createElement("div",{ref:woLeftMeterRef,className:"h-full bg-cyan-400 transition-all duration-75",style:{width:'0%'}}))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-[8px] text-slate-500 w-2"},"R"),/*#__PURE__*/React.createElement("div",{className:"w-16 h-1.5 bg-slate-900 rounded-sm overflow-hidden flex items-center"},/*#__PURE__*/React.createElement("div",{ref:woRightMeterRef,className:"h-full bg-teal-500 transition-all duration-75",style:{width:'0%'}}))))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-4 bg-slate-950 px-3 py-1 rounded border border-slate-800/80 flex-1 justify-around"},/*#__PURE__*/React.createElement("span",{className:"font-bold text-slate-300 uppercase tracking-widest text-[9px]"},"Scope"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",null,"Channel"),/*#__PURE__*/React.createElement("select",{value:woChannel,onChange:e=>setWoChannel(e.target.value),className:"bg-slate-900 border border-slate-700 rounded px-1.5 py-0.5 text-[9px] text-slate-300 outline-none"},/*#__PURE__*/React.createElement("option",{value:"stereo"},"Stereo"),/*#__PURE__*/React.createElement("option",{value:"left"},"Left Only"),/*#__PURE__*/React.createElement("option",{value:"right"},"Right Only"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",null,"Mode"),/*#__PURE__*/React.createElement("select",{value:woMode,onChange:e=>setWoMode(e.target.value),className:"bg-slate-900 border border-slate-700 rounded px-1.5 py-0.5 text-[9px] text-slate-300 outline-none"},/*#__PURE__*/React.createElement("option",{value:"waveform"},"Waveform"),/*#__PURE__*/React.createElement("option",{value:"envelope"},"Envelope"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",null,"Duration:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold w-10 text-right"},woDuration.toFixed(3),"s"),/*#__PURE__*/React.createElement("input",{type:"range",min:"0.5",max:"5.0",step:"0.1",value:woDuration,onChange:e=>setWoDuration(parseFloat(e.target.value)),className:"w-16 h-1 cursor-pointer accent-cyan-400"})),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",null,"V.Zoom:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold w-12 text-right"},woZoom.toFixed(1)," dB"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"24",step:"0.5",value:woZoom,onChange:e=>setWoZoom(parseFloat(e.target.value)),className:"w-16 h-1 cursor-pointer accent-cyan-400"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>setWoPaused(!woPaused),className:`px-3 py-1 rounded font-bold border transition shadow-md shrink-0 flex items-center gap-1 ${woPaused?'bg-amber-800 hover:bg-amber-700 border-amber-500 text-amber-100':'bg-slate-800 hover:bg-slate-700 border-slate-700 text-slate-200'}`},woPaused?'Resume':'Pause')))),/*#__PURE__*/React.createElement("div",{className:"w-64 bg-slate-900 border-l border-slate-800/80 p-3 flex flex-col justify-between shadow-2xl oz-font-mono text-xs z-20 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-800 pb-2 mb-2"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-white tracking-wider uppercase flex items-center gap-1.5"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders",className:"w-3 h-3 text-cyan-400"})," I/O METERS"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] bg-cyan-950 border border-cyan-800 text-cyan-300 px-1 py-0.5 rounded"},"TRUE PEAK")),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2 text-[10px] text-center mb-2"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800/80 p-1.5 rounded"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold"},"IN PEAK"),/*#__PURE__*/React.createElement("div",{id:"inPeakText",className:"text-cyan-400 font-bold oz-font-mono"},"-inf dB")),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800/80 p-1.5 rounded"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold"},"OUT PEAK"),/*#__PURE__*/React.createElement("div",{id:"outPeakText",className:"text-emerald-400 font-bold oz-font-mono"},"-inf dB"))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2 text-[10px] text-center mb-2"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800/80 p-1.5 rounded"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold"},"OUT RMS"),/*#__PURE__*/React.createElement("div",{id:"outRmsText",className:"text-sky-300 font-bold oz-font-mono"},"-inf")),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800/80 p-1.5 rounded"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold"},"LOUDNESS"),/*#__PURE__*/React.createElement("div",{id:"lufsText",className:"text-fuchsia-300 font-bold oz-font-mono"},"--.-"))),/*#__PURE__*/React.createElement("div",{className:"flex-1 bg-slate-950 border border-slate-800/90 rounded-xl p-2 flex justify-around relative overflow-hidden my-1"},/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500 mb-1"},"IN"),/*#__PURE__*/React.createElement("div",{className:"flex-1 w-5 bg-slate-900 border border-slate-800 rounded relative overflow-hidden flex items-end"},/*#__PURE__*/React.createElement("canvas",{ref:inMeterCanvasRef,className:"w-full h-full block"}))),/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500 mb-1"},"OUT"),/*#__PURE__*/React.createElement("div",{className:"flex-1 w-6 bg-slate-900 border border-slate-800 rounded relative overflow-hidden flex items-end"},/*#__PURE__*/React.createElement("canvas",{ref:outMeterCanvasRef,className:"w-full h-full block"})))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1.5 mt-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>setOzState(prev=>({...prev,isBypassed:!prev.isBypassed})),className:`py-1.5 rounded text-[10px] font-bold border transition-colors ${ozState.isBypassed?'bg-cyan-600 border-cyan-500 text-white':'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}`},"Bypass"),/*#__PURE__*/React.createElement("button",{className:"bg-slate-800 hover:bg-slate-700 text-slate-200 py-1.5 rounded text-[10px] font-bold border border-slate-700 transition-colors"},"Gain Match"),/*#__PURE__*/React.createElement("button",{className:"bg-slate-800 hover:bg-slate-700 text-slate-300 py-1.5 rounded text-[10px] border border-slate-700"},"Codec"),/*#__PURE__*/React.createElement("button",{className:"bg-slate-800 hover:bg-slate-700 text-slate-300 py-1.5 rounded text-[10px] border border-slate-700"},"Dither"))))),addModuleOpen&&/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[110] 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 oz-font-mono"},"TH\xCAM MODULE V\xC0O MASTER 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"},[{t:'eqpro',c:'text-teal-400',i:'chart-area',d:'Parametric / Graphic EQ PRO — Pro-Q style, 8 bands, interactive canvas + spectrum.'},{t:'compressor',c:'text-amber-400',i:'compress',d:'Nén dynamic range, glue & punch cho master.'},{t:'limiter',c:'text-rose-400',i:'shield-half',d:'True-Peak ceiling (ratio 20:1, knee 0) chống clipping.'},{t:'exciter',c:'text-purple-400',i:'wand-2',d:'Saturation hài cho warmth và top-end brilliance.'},{t:'rebalance',c:'text-sky-400',i:'sliders-horizontal',d:'Cân bằng Mid/Side (Vocal/Bass vs stereo width).'},{t:'eq',c:'text-cyan-400',i:'activity',d:'EQ 4-band (lowshelf, 2× peaking, highshelf) + presets.'},{t:'imager',c:'text-fuchsia-400',i:'radio',d:'Stereo width 4-band M/S + vectorscope/correlation.'},{t:'maximizer',c:'text-emerald-400',i:'gauge',d:'Maximizer: boost, soft clip, upward comp, ceiling.'}].map(m=>/*#__PURE__*/React.createElement("button",{key:m.t,onClick:()=>addModuleToChain(m.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 ${m.c} flex items-center gap-1.5`},/*#__PURE__*/React.createElement("i",{"data-lucide":m.i,className:"w-3.5 h-3.5"})," ",MODULE_META[m.t].name),/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-slate-400"},m.d)))),/*#__PURE__*/React.createElement("div",{className:"border-t border-slate-800 pt-3"},/*#__PURE__*/React.createElement("div",{className:"text-[10px] font-bold text-teal-400 uppercase tracking-wider oz-font-mono mb-2"},"VST FX (Ozone...)"),/*#__PURE__*/React.createElement("button",{onClick:()=>setMasterVstPickerOpen(true),className:"w-full bg-slate-950 hover:bg-slate-800 border border-dashed border-slate-700 hover:border-teal-600 text-teal-300 rounded px-2 py-2 text-[11px] text-left transition-colors",title:"Plugin Picker theo category (EQ / Reverb / Comp / Utility)"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plug",className:"w-3 h-3 inline-block mr-1"})," Ch\u1ECDn VST FX t\u1EEB th\u01B0 vi\u1EC7n \u2014 Picker: EQ / Reverb / Comp / Utility")))),/*#__PURE__*/React.createElement(VstPluginPicker,{open:masterVstPickerOpen,onClose:()=>setMasterVstPickerOpen(false),plugins:masterFxList,loading:masterFxLoading,onPick:f=>addVstToChain(f.path)}));};// ── Media Explorer Panel (from md/51_MEDIA_EXPLORER.md) ── +const MODULE_META={eq:{name:'Dynamic EQ',sub:'4-Band Peak',icon:'activity',color:'#22d3ee'},eqpro:{name:'Parametric / Graphic EQ PRO',sub:'Pro-Q style · 8 bands · interactive',icon:'chart-area',color:'#2dd4bf'},imager:{name:'Imager',sub:'4-Band Width',icon:'radio',color:'#a855f7'},maximizer:{name:'Maximizer',sub:'IRC IV True Peak',icon:'gauge',color:'#34d399'},compressor:{name:'Bus Compressor',sub:'Glue & Punch',icon:'compress',color:'#fbbf24'},limiter:{name:'Brickwall Limiter',sub:'True-Peak 20:1',icon:'shield-half',color:'#f43f5e'},exciter:{name:'Harmonic Exciter',sub:'Saturation & Air',icon:'wand-2',color:'#c084fc'},rebalance:{name:'Master Rebalance',sub:'M/S Balance',icon:'sliders-horizontal',color:'#38bdf8'},vst3:{name:'VST FX',sub:'VST3 plugin',icon:'plug',color:'#2dd4bf'}};const chainFlag=type=>type==='eq'?'eqActive':type==='eqpro'?'eqproActive':type==='imager'?'imagerActive':type==='maximizer'?'maximizerActive':type==='compressor'?'compActive':type==='limiter'?'limActive':type==='exciter'?'excActive':type==='vst3'?'vstFxActive':'rebalActive';const chainActive=type=>!!ozState[chainFlag(type)];const toggleChainModule=modId=>{setOzState(prev=>{const chain=prev.chain.map(m=>{if(m.id!==modId)return m;const next=!m.active;return{...m,active:next};});const flags={};chain.forEach(m=>{flags[chainFlag(m.type)]=!!m.active;});const target=chain.find(m=>m.id===modId);let vstFxChain=prev.vstFxChain||[];if(target&&target.type==='vst3'){const vi=vstFxChain.findIndex(s=>s.path===target.path);if(vi>=0)vstFxChain=vstFxChain.map((s,si)=>si===vi?{...s,bypass:!target.active}:s);}return{...prev,chain,...flags,vstFxChain};});};const removeChainModule=modId=>{const removedMod=(ozState.chain||[]).find(m=>m.id===modId);if(removedMod&&removedMod.type==='vst3'&&masterGuiOpen&&masterGuiOpen.path===removedMod.path)closeMasterGui();setOzState(prev=>{const removed=prev.chain.find(m=>m.id===modId);const chain=prev.chain.filter(m=>m.id!==modId);const flags={};chain.forEach(m=>{flags[chainFlag(m.type)]=!!m.active;});const activeModule=prev.activeModule;let vstFxChain=prev.vstFxChain||[];if(removed&&removed.type==='vst3'){const vi=vstFxChain.findIndex(s=>s.path===removed.path);if(vi>=0)vstFxChain=[...vstFxChain.slice(0,vi),...vstFxChain.slice(vi+1)];}return{...prev,chain,...flags,vstFxChain,activeModule:chain.length?chain.some(m=>m.type===activeModule)?activeModule:chain[chain.length-1].type:null};});};const reorderChain=(fromIdx,toIdx)=>{if(fromIdx===toIdx)return;setOzState(prev=>{const chain=[...prev.chain];const moved=chain.splice(fromIdx,1)[0];chain.splice(toIdx,0,moved);const vstFxChain=chain.filter(m=>m&&m.type==='vst3').map(s=>({type:'vst3',path:s.path||'',name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass}));return{...prev,chain,vstFxChain};});};const addModuleToChain=type=>{const meta=MODULE_META[type];const id='mod_'+type+'_'+Date.now();const entry={id,type,name:meta.name,active:true};if(type==='eqpro')entry.params={amount:100,bands:JSON.parse(JSON.stringify(EQPRO_DEFAULT_BANDS))};setOzState(prev=>({...prev,chain:[...(prev.chain||[]),entry],[chainFlag(type)]:true,activeModule:type}));setAddModuleOpen(false);};const addVstToChain=path=>{const fx=(masterFxList||[]).find(f=>f.path===path);if(!fx)return;const id='mod_vst_'+Date.now();const entry={id,type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false,active:true};setOzState(prev=>({...prev,chain:[...(prev.chain||[]),entry],vstFxChain:[...(prev.vstFxChain||[]),{type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false}]}));setAddModuleOpen(false);};const updateChainEntryParams=(modId,patch)=>{setOzState(prev=>({...prev,chain:(prev.chain||[]).map(m=>m.id===modId?{...m,params:{...(m.params||{}),...patch}}:m)}));};// ── EQ Preset Library (mastering_expand.md §II.1) ── +const EQ_PRESET_LIBRARY={flat:{name:'Flat / Reset',bands:[{id:1,type:'lowshelf',freq:100,gain:0.0,q:0.7},{id:2,type:'peaking',freq:800,gain:0.0,q:0.7},{id:3,type:'peaking',freq:3200,gain:0.0,q:1.2},{id:4,type:'highshelf',freq:10000,gain:0.0,q:0.7}]},vocal_clarity:{name:'Vocal Unmask & Clarity',bands:[{id:1,type:'lowshelf',freq:90,gain:-2.5,q:0.7},{id:2,type:'peaking',freq:500,gain:-1.8,q:1.0},{id:3,type:'peaking',freq:2800,gain:3.2,q:1.2},{id:4,type:'highshelf',freq:12000,gain:2.0,q:0.7}]},bass_punch:{name:'EDM Low-End Punch',bands:[{id:1,type:'lowshelf',freq:80,gain:4.0,q:0.8},{id:2,type:'peaking',freq:300,gain:-3.0,q:1.4},{id:3,type:'peaking',freq:4000,gain:1.5,q:1.0},{id:4,type:'highshelf',freq:10000,gain:1.0,q:0.7}]},warm_tape:{name:'Warm Vintage Analog',bands:[{id:1,type:'lowshelf',freq:120,gain:2.0,q:0.6},{id:2,type:'peaking',freq:1500,gain:1.0,q:0.5},{id:3,type:'peaking',freq:5000,gain:-2.0,q:1.0},{id:4,type:'highshelf',freq:8000,gain:-3.0,q:0.7}]}};const applyEQPreset=presetKey=>{const preset=EQ_PRESET_LIBRARY[presetKey];if(!preset)return;// Phase 4.5: EQ DSP chạy trong bridge — preset chỉ sync state; engine +// nhận qua fxRtPushAllParams() ở applyMasteringSettings. +// Sync UI knobs + canvas: [eqLowGain, eqMid1Gain, eqMid2Gain, eqHighGain] +setOzState(prev=>({...prev,eqLowGain:preset.bands[0]?preset.bands[0].gain:prev.eqLowGain,eqMid1Gain:preset.bands[1]?preset.bands[1].gain:prev.eqMid1Gain,eqMid2Gain:preset.bands[2]?preset.bands[2].gain:prev.eqMid2Gain,eqHighGain:preset.bands[3]?preset.bands[3].gain:prev.eqHighGain,eqPreset:presetKey}));};const bandKnob=(param,min,max,val,unit,label,freq,color,filterType)=>/*#__PURE__*/React.createElement("div",{className:"bg-slate-900/80 border border-slate-800 p-2 flex flex-col justify-between items-center rounded-lg"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between text-[10px] font-bold w-full",style:{color}},/*#__PURE__*/React.createElement("span",null,label),/*#__PURE__*/React.createElement("span",{className:"text-[9px] font-mono text-slate-400"},freq)),/*#__PURE__*/React.createElement("div",{className:"my-1"},/*#__PURE__*/React.createElement(MasteringKnob,{param:param,min:min,max:max,value:ozState[param]!==undefined?ozState[param]:val,unit:unit,color:color,onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"text-[9px] text-slate-500 font-mono text-center mt-1"},filterType));return/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm",onClick:onClose},/*#__PURE__*/React.createElement("div",{id:"masteringModalBody",className:"flex flex-col bg-slate-950 text-slate-200 w-[95vw] h-[92vh] max-w-[1400px] rounded-2xl border border-slate-800/80 shadow-2xl overflow-hidden",onClick:e=>e.stopPropagation(),style:{fontFamily:"'Inter', sans-serif"}},/*#__PURE__*/React.createElement("header",{className:"h-12 bg-slate-900/90 border-b border-slate-800/80 flex items-center justify-between px-4 z-30 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3"},/*#__PURE__*/React.createElement("div",{className:"w-7 h-7 rounded-lg bg-cyan-600 flex items-center justify-center font-bold text-white shadow-lg shadow-cyan-950"},/*#__PURE__*/React.createElement("i",{"data-lucide":"zap",className:"w-3.5 h-3.5"})),/*#__PURE__*/React.createElement("div",null,/*#__PURE__*/React.createElement("h1",{className:"text-xs font-bold tracking-wider text-white flex items-center gap-2"},"MASTER FX CHAIN ",/*#__PURE__*/React.createElement("span",{className:"text-[9px] bg-cyan-950 text-cyan-400 border border-cyan-800/80 px-1.5 py-0.5 rounded oz-font-mono"},"WEB MASTERING V10.5")))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3 bg-slate-950 px-3 py-1 rounded-lg border border-slate-800/80"},/*#__PURE__*/React.createElement("button",{onClick:startAudioDemo,className:"px-3 py-1 bg-cyan-600 hover:bg-cyan-500 text-white rounded text-xs font-semibold flex items-center gap-1.5 transition-all shadow-md shadow-cyan-950"},/*#__PURE__*/React.createElement("i",{"data-lucide":"play",className:"w-3 h-3"})," ",/*#__PURE__*/React.createElement("span",null,"Play Reference")),/*#__PURE__*/React.createElement("button",{onClick:stopAudioDemo,className:"px-3 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs font-semibold flex items-center gap-1.5 transition-all"},/*#__PURE__*/React.createElement("i",{"data-lucide":"square",className:"w-3 h-3"})," ",/*#__PURE__*/React.createElement("span",null,"Stop")),/*#__PURE__*/React.createElement("div",{className:"h-4 w-[1px] bg-slate-800 mx-1"}),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 text-xs oz-font-mono"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400 text-[11px]"},"Preset:"),/*#__PURE__*/React.createElement("select",{className:"bg-slate-900 border border-slate-700 rounded px-2 py-0.5 text-xs text-cyan-300 outline-none focus:border-cyan-500"},/*#__PURE__*/React.createElement("option",{value:"adaptive"},"Adaptive Dynamic Master"),/*#__PURE__*/React.createElement("option",{value:"edm_club"},"EDM / Club Punch Maximizer"),/*#__PURE__*/React.createElement("option",{value:"wide_space"},"Cinematic Stereo Expansion"),/*#__PURE__*/React.createElement("option",{value:"transparent"},"Transparent High-Clarity Limiter")))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3 text-xs oz-font-mono"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400 text-[11px]"},"Target LUFS:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold bg-slate-950 border border-cyan-900/60 px-2 py-0.5 rounded"},"-11.0 LUFS"),/*#__PURE__*/React.createElement("button",{onClick:()=>{getAudioContext();setMasterConnected(prev=>!prev);},className:`px-2 py-0.5 rounded text-[10px] font-bold border transition-colors ${masterConnected?'bg-emerald-700 border-emerald-500 text-white':'bg-slate-800 border-slate-700 text-slate-300 hover:bg-slate-700'}`},masterConnected?'Master ON':'Master OFF'),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"px-2 py-0.5 rounded text-[10px] font-bold border transition-colors bg-slate-800 border-slate-700 text-slate-300 hover:bg-[#c2410c] hover:border-red-600 hover:text-white"},"\u0110\xF3ng"),/*#__PURE__*/React.createElement("button",{onClick:onClose,className:"text-slate-500 hover:text-slate-300 ml-1"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-4 h-4"})))),/*#__PURE__*/React.createElement("div",{className:"h-16 bg-slate-950 border-b border-slate-800 px-4 flex items-center gap-2 overflow-x-auto shrink-0 oz-scrollbar"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-500 uppercase tracking-widest mr-1 oz-font-mono shrink-0"},"CHAIN:"),(ozState.chain||[]).map((mod,idx)=>{const isVst=mod.type==='vst3';const meta=MODULE_META[mod.type]||{name:mod.type,sub:'',icon:'circle',color:'#94a3b8'};const isEditing=!isVst&&ozState.activeModule===mod.type||isVst&&activeMasterVstPath===mod.path;const isOn=isVst?mod.active!==false:chainActive(mod.type);return/*#__PURE__*/React.createElement("div",{key:mod.id,draggable:true,onDragStart:e=>{dragChainIndexRef.current=idx;e.dataTransfer.effectAllowed='move';},onDragOver:e=>{e.preventDefault();e.dataTransfer.dropEffect='move';},onDrop:e=>{e.preventDefault();const from=dragChainIndexRef.current;if(from!==null&&from!==idx)reorderChain(from,idx);dragChainIndexRef.current=null;},onClick:()=>{if(mod.type==='vst3'){openMasterVstGui({path:mod.path,name:mod.name||mod.path});}else{closeMasterGui();switchModule(mod.type);}},className:`w-40 h-12 rounded-lg p-2 flex items-center justify-between cursor-pointer transition-all shrink-0 ${isEditing?'oz-card-active':'oz-card'}`},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 min-w-0"},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleChainModule(mod.id);},className:"w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0",style:{backgroundColor:isOn?'#38bdf8':'#334155',color:isOn?'#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"},isVst?mod.name||meta.name:meta.name),/*#__PURE__*/React.createElement("div",{className:"text-[9px] oz-font-mono truncate",style:{color:meta.color}},idx+1,". ",isVst?mod.path?mod.path.split(/[\\/]/).pop():'VST3':meta.sub))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1 shrink-0"},/*#__PURE__*/React.createElement("i",{"data-lucide":meta.icon,className:"w-3 h-3 text-slate-500"}),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();removeChainModule(mod.id);},className:"text-slate-600 hover:text-red-400 text-xs w-4 h-4 flex items-center justify-center shrink-0",title:"X\xF3a module"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"}))));}),/*#__PURE__*/React.createElement("button",{onClick:()=>setAddModuleOpen(true),className:"w-24 h-12 rounded-lg border border-dashed border-slate-800 flex items-center justify-center text-slate-600 hover:text-slate-400 hover:border-slate-600 cursor-pointer transition-all shrink-0",title:"Th\xEAm module v\xE0o chain"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plus",className:"w-4 h-4"}))),/*#__PURE__*/React.createElement("main",{className:"flex-1 flex overflow-hidden min-h-0"},masterGuiOpen?/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col bg-slate-950 relative min-h-0"},/*#__PURE__*/React.createElement(VstGuiEmbed,{url:masterGuiOpen.url,onClose:closeMasterGui,onDead:()=>{window.showToast&&window.showToast('Bridge VST GUI đã mất kết nối — trả về graph','error');closeMasterGui();}})):/*#__PURE__*/React.createElement("div",{className:"flex-1 flex flex-col bg-slate-950 relative overflow-y-auto oz-scrollbar"},/*#__PURE__*/React.createElement("div",{className:"h-9 bg-slate-900/60 border-b border-slate-800/80 px-4 flex items-center justify-between text-xs oz-font-mono shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-3"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400 flex items-center gap-1.5"},/*#__PURE__*/React.createElement("i",{"data-lucide":"headphones",className:"w-3 h-3 text-cyan-400"})," Delta Listen"),/*#__PURE__*/React.createElement("select",{className:"bg-slate-950 border border-slate-800 rounded px-2 py-0.5 text-[11px] text-slate-300 outline-none"},/*#__PURE__*/React.createElement("option",{value:"irc4"},"IRC IV - Classic"),/*#__PURE__*/React.createElement("option",{value:"irc3"},"IRC III - Balanced"),/*#__PURE__*/React.createElement("option",{value:"irc2"},"IRC II - Crisp"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 text-[11px]"},ozState.activeModule==='eq'&&/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"EQ Presets:"),/*#__PURE__*/React.createElement("select",{value:ozState.eqPreset||'flat',onChange:e=>applyEQPreset(e.target.value),className:"bg-slate-950 border border-slate-700 text-cyan-300 rounded px-2 py-0.5 text-[11px] outline-none focus:border-cyan-500"},Object.keys(EQ_PRESET_LIBRARY).map(k=>/*#__PURE__*/React.createElement("option",{key:k,value:k},EQ_PRESET_LIBRARY[k].name)))),/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"Learn Input Gain:"),/*#__PURE__*/React.createElement("button",{className:"bg-cyan-950 text-cyan-300 border border-cyan-800/80 px-2 py-0.5 rounded font-bold hover:bg-cyan-900 transition-colors"},"-11.0 LUFS"))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='eq'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"relative w-full h-64 bg-slate-950 border border-slate-800/90 rounded-xl overflow-hidden shadow-inner cursor-crosshair"},/*#__PURE__*/React.createElement("canvas",{ref:eqCanvasRef,className:"w-full h-full block"})),/*#__PURE__*/React.createElement("div",{className:"oz-panel p-3 rounded-xl grid grid-cols-4 gap-3"},bandKnob('eqLowGain',-12,12,1.5,'dB','BAND 1 (LOW)','100 Hz','#22d3ee','Shelf Filter'),bandKnob('eqMid1Gain',-12,12,-1.0,'dB','BAND 2 (MID LOW)','822 Hz','#fbbf24','Dynamic Bell (Q: 0.7)'),bandKnob('eqMid2Gain',-12,12,2.0,'dB','BAND 3 (MID HIGH)','3.2 kHz','#a855f7','Dynamic Bell (Q: 1.2)'),bandKnob('eqHighGain',-12,12,1.8,'dB','BAND 4 (HIGH)','10 kHz','#34d399','High Shelf'))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='eqpro'?'':'hidden'}`},(()=>{const chainMods=ozState.chain||[];const cm=chainMods.find(m=>m.type==='eqpro'&&m.active!==false)||chainMods.find(m=>m.type==='eqpro');if(!cm)return/*#__PURE__*/React.createElement("div",{className:"text-xs text-slate-500 font-mono py-10 text-center"},"Ch\u01B0a c\xF3 module EQ PRO trong chain \u2014 b\u1EA5m [+] \u0111\u1EC3 th\xEAm.");return/*#__PURE__*/React.createElement(InteractiveEqPro,{track:null,params:cm.params||{},onChange:next=>updateChainEntryParams(cm.id,next),getModule:()=>null,spectrumModules:()=>[],applyTo:fn=>{const chain=fxRtChain();const slot=chain.findIndex(m=>m&&m.type==='eqpro');if(slot<0)return;fn({setBand:(idx,patch)=>{Object.keys(patch||{}).forEach(k=>{if(fxRt&&fxRt.ws&&fxRt.ws.readyState===1)fxRt.ws.send(JSON.stringify({cmd:'set_param',slot,key:'band'+idx+'_'+k,value:patch[k]}));});},setAmount:v=>{if(fxRt&&fxRt.ws&&fxRt.ws.readyState===1)fxRt.ws.send(JSON.stringify({cmd:'set_param',slot,key:'amount',value:v}));},syncBands:bands=>{(bands||[]).forEach((b,i)=>{['freq','gain','q'].forEach(k=>{if(fxRt&&fxRt.ws&&fxRt.ws.readyState===1)fxRt.ws.send(JSON.stringify({cmd:'set_param',slot,key:'band'+i+'_'+k,value:b[k]}));});});}});}});})()),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='imager'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-12 gap-4 flex-1"},/*#__PURE__*/React.createElement("div",{className:"col-span-8 bg-slate-950 border border-slate-800/90 rounded-xl p-3 flex flex-col relative shadow-inner"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-cyan-400 uppercase oz-font-mono mb-2 flex items-center justify-between w-full"},/*#__PURE__*/React.createElement("span",null,/*#__PURE__*/React.createElement("i",{"data-lucide":"radio",className:"w-3.5 h-3.5"})," Polar Vectorscope & Correlation Meter"),/*#__PURE__*/React.createElement("span",{className:"text-[11px] text-slate-400 normal-case"},"Corr: ",/*#__PURE__*/React.createElement("span",{id:"corrText",className:"font-bold text-emerald-400"},"1.00"))),/*#__PURE__*/React.createElement("div",{className:"flex-1 relative w-full h-56 flex items-center justify-center"},/*#__PURE__*/React.createElement("canvas",{ref:imagerCanvasRef,className:"w-full h-full block"}))),/*#__PURE__*/React.createElement("div",{className:"col-span-4 oz-panel p-3 rounded-xl flex flex-col justify-between"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-slate-300 uppercase oz-font-mono"},"4-Band Stereo Width"),/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-slate-500 oz-font-mono leading-snug"},"0% = Mono \xB7 100% = Original \xB7 200% = 2\xD7 Width"),/*#__PURE__*/React.createElement("div",{className:"space-y-3 my-auto"},[{id:'w1',label:'Band 1 (20-100Hz)',color:'#22d3ee',val:ozState.w1},{id:'w2',label:'Band 2 (100Hz-1kHz)',color:'#fbbf24',val:ozState.w2},{id:'w3',label:'Band 3 (1k-6kHz)',color:'#a855f7',val:ozState.w3},{id:'w4',label:'Band 4 (6k-20kHz)',color:'#34d399',val:ozState.w4}].map(b=>/*#__PURE__*/React.createElement("div",{key:b.id},/*#__PURE__*/React.createElement("div",{className:"flex justify-between text-[11px] font-mono mb-1"},/*#__PURE__*/React.createElement("span",{style:{color:b.color,fontWeight:700}},b.label),/*#__PURE__*/React.createElement("span",{id:b.id+'Val'},b.val,"%")),/*#__PURE__*/React.createElement("input",{type:"range",min:"0",max:"200",value:b.val,onChange:e=>setOzState(prev=>({...prev,[b.id]:parseInt(e.target.value)})),className:"w-full h-1 cursor-pointer",style:{accentColor:b.color}}))))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='maximizer'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-slate-400 uppercase oz-font-mono mb-3"},"Maximizer Gain Boost"),/*#__PURE__*/React.createElement("div",{className:"my-2"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxGain",min:0,max:12,value:ozState.maxGain,unit:"dB",color:"#22d3ee",size:"large",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"w-full mt-4 bg-slate-950 p-2 rounded-lg border border-slate-800 text-xs oz-font-mono flex flex-col gap-1.5"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between items-center w-full"},/*#__PURE__*/React.createElement("span",{className:"text-slate-400"},"Ceiling Level:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold"},ozState.ceiling.toFixed(2)," dB")),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"0",step:"0.1",value:ozState.ceiling,onChange:e=>setOzState(prev=>({...prev,ceiling:parseFloat(e.target.value)})),className:"w-full h-1 cursor-pointer accent-cyan-400"}))),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxUpward",min:0,max:10,value:ozState.maxUpward,unit:"dB",label:"UPWARD COMPRESS",color:"#22d3ee",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxSoftClip",min:0,max:100,value:ozState.maxSoftClip,unit:"%",label:"SOFT CLIPPER",color:"#fbbf24",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"maxTransient",min:0,max:100,value:ozState.maxTransient,unit:"%",label:"TRANSIENT EMPHASIS",color:"#34d399",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))}))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='compressor'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-amber-400 uppercase oz-font-mono mb-3"},"Bus Compressor"),/*#__PURE__*/React.createElement("div",{className:"my-2"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"compMakeup",min:0,max:12,value:ozState.compMakeup,unit:"dB",label:"MAKE-UP GAIN",color:"#fbbf24",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("button",{onClick:()=>toggleChainModule((ozState.chain||[]).find(m=>m.type==='compressor')?.id),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.compActive?'bg-amber-700 border-amber-500 text-white':'bg-slate-800 border-slate-700 text-slate-300'}`},ozState.compActive?'ON':'OFF')),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"compThreshold",min:-60,max:0,value:ozState.compThreshold,unit:"dB",label:"THRESHOLD",color:"#fbbf24",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"compRatio",min:1,max:20,value:ozState.compRatio,unit:":1",label:"RATIO",color:"#f59e0b",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2"},"Glue & Punch",/*#__PURE__*/React.createElement("br",null),"Attack 20ms \xB7 Release 250ms \xB7 Knee 8dB"))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='limiter'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-rose-400 uppercase oz-font-mono mb-3"},"Brickwall Limiter"),/*#__PURE__*/React.createElement("button",{onClick:()=>toggleChainModule((ozState.chain||[]).find(m=>m.type==='limiter')?.id),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.limActive?'bg-rose-700 border-rose-500 text-white':'bg-slate-800 border-slate-700 text-slate-300'}`},ozState.limActive?'ON':'OFF')),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"limThreshold",min:-24,max:0,value:ozState.limThreshold,unit:"dB",label:"CEILING",color:"#f43f5e",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2"},"True-Peak limiting",/*#__PURE__*/React.createElement("br",null),"Ratio 20:1 \xB7 Knee 0dB",/*#__PURE__*/React.createElement("br",null),"Attack 1ms \xB7 Release 50ms"))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='exciter'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-purple-400 uppercase oz-font-mono mb-3"},"Harmonic Exciter"),/*#__PURE__*/React.createElement("button",{onClick:()=>toggleChainModule((ozState.chain||[]).find(m=>m.type==='exciter')?.id),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.excActive?'bg-purple-700 border-purple-500 text-white':'bg-slate-800 border-slate-700 text-slate-300'}`},ozState.excActive?'ON':'OFF')),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"excDrive",min:0,max:100,value:ozState.excDrive,unit:"%",label:"DRIVE / MIX",color:"#c084fc",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2"},"WaveShaper saturation",/*#__PURE__*/React.createElement("br",null),"High-pass 2kHz",/*#__PURE__*/React.createElement("br",null),"4\xD7 oversampled \xB7 wet/dry mix"))))),/*#__PURE__*/React.createElement("div",{className:`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule==='rebalance'?'':'hidden'}`},/*#__PURE__*/React.createElement("div",{className:"oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center"},/*#__PURE__*/React.createElement("div",{className:"col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-sky-400 uppercase oz-font-mono mb-3"},"Master Rebalance (M/S)"),/*#__PURE__*/React.createElement("button",{onClick:()=>toggleChainModule((ozState.chain||[]).find(m=>m.type==='rebalance')?.id),className:`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.rebalActive?'bg-sky-700 border-sky-500 text-white':'bg-slate-800 border-slate-700 text-slate-300'}`},ozState.rebalActive?'ON':'OFF')),/*#__PURE__*/React.createElement("div",{className:"col-span-8 grid grid-cols-3 gap-4"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"rebalMid",min:-12,max:12,value:ozState.rebalMid,unit:"dB",label:"MID GAIN",color:"#38bdf8",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement(MasteringKnob,{param:"rebalSide",min:-12,max:12,value:ozState.rebalSide,unit:"dB",label:"SIDE GAIN",color:"#22d3ee",onChange:(p,v)=>setOzState(prev=>({...prev,[p]:v}))})),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 p-3 border border-slate-800 rounded-xl flex flex-col items-center justify-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-slate-500 oz-font-mono text-center leading-snug px-2"},"ChannelSplitter + M/S gains",/*#__PURE__*/React.createElement("br",null),"Center (vocal/bass) vs Sides (stereo width)"))))),/*#__PURE__*/React.createElement("div",{className:"border-t border-slate-800 bg-slate-900/60 p-3 flex flex-col shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between pb-1.5 border-b border-slate-800/80 mb-2"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},/*#__PURE__*/React.createElement("span",{className:"text-[11px] font-bold text-white uppercase tracking-wider"},"Wave Observer"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] bg-cyan-950 text-cyan-400 border border-cyan-800/60 px-1.5 py-0.2 rounded font-mono"},"Real-time Oscilloscope")),/*#__PURE__*/React.createElement("div",{className:"flex gap-2"},['Scope','Settings','Help','About'].map(tab=>/*#__PURE__*/React.createElement("button",{key:tab,className:`px-2 py-0.5 rounded text-[10px] font-bold ${tab==='Scope'?'bg-cyan-950 text-cyan-300 border border-cyan-800/60':'text-slate-400 hover:text-slate-200'}`},tab)))),/*#__PURE__*/React.createElement("div",{className:"relative w-full h-32 bg-[#090d16] border border-slate-800/80 rounded-lg overflow-hidden mb-2"},/*#__PURE__*/React.createElement("canvas",{ref:woCanvasRef,className:"w-full h-full block"})),/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between gap-4 text-[10px] oz-font-mono text-slate-400 mt-1 select-none"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2 bg-slate-950 px-2.5 py-1.5 rounded border border-slate-800/80 shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-300"},"Input"),/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1 w-20"},/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-[8px] text-slate-500 w-2"},"L"),/*#__PURE__*/React.createElement("div",{className:"w-16 h-1.5 bg-slate-900 rounded-sm overflow-hidden flex items-center"},/*#__PURE__*/React.createElement("div",{ref:woLeftMeterRef,className:"h-full bg-cyan-400 transition-all duration-75",style:{width:'0%'}}))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1"},/*#__PURE__*/React.createElement("span",{className:"text-[8px] text-slate-500 w-2"},"R"),/*#__PURE__*/React.createElement("div",{className:"w-16 h-1.5 bg-slate-900 rounded-sm overflow-hidden flex items-center"},/*#__PURE__*/React.createElement("div",{ref:woRightMeterRef,className:"h-full bg-teal-500 transition-all duration-75",style:{width:'0%'}}))))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-4 bg-slate-950 px-3 py-1 rounded border border-slate-800/80 flex-1 justify-around"},/*#__PURE__*/React.createElement("span",{className:"font-bold text-slate-300 uppercase tracking-widest text-[9px]"},"Scope"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",null,"Channel"),/*#__PURE__*/React.createElement("select",{value:woChannel,onChange:e=>setWoChannel(e.target.value),className:"bg-slate-900 border border-slate-700 rounded px-1.5 py-0.5 text-[9px] text-slate-300 outline-none"},/*#__PURE__*/React.createElement("option",{value:"stereo"},"Stereo"),/*#__PURE__*/React.createElement("option",{value:"left"},"Left Only"),/*#__PURE__*/React.createElement("option",{value:"right"},"Right Only"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",null,"Mode"),/*#__PURE__*/React.createElement("select",{value:woMode,onChange:e=>setWoMode(e.target.value),className:"bg-slate-900 border border-slate-700 rounded px-1.5 py-0.5 text-[9px] text-slate-300 outline-none"},/*#__PURE__*/React.createElement("option",{value:"waveform"},"Waveform"),/*#__PURE__*/React.createElement("option",{value:"envelope"},"Envelope"))),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",null,"Duration:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold w-10 text-right"},woDuration.toFixed(3),"s"),/*#__PURE__*/React.createElement("input",{type:"range",min:"0.5",max:"5.0",step:"0.1",value:woDuration,onChange:e=>setWoDuration(parseFloat(e.target.value)),className:"w-16 h-1 cursor-pointer accent-cyan-400"})),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-1.5"},/*#__PURE__*/React.createElement("span",null,"V.Zoom:"),/*#__PURE__*/React.createElement("span",{className:"text-cyan-400 font-bold w-12 text-right"},woZoom.toFixed(1)," dB"),/*#__PURE__*/React.createElement("input",{type:"range",min:"-12",max:"24",step:"0.5",value:woZoom,onChange:e=>setWoZoom(parseFloat(e.target.value)),className:"w-16 h-1 cursor-pointer accent-cyan-400"}))),/*#__PURE__*/React.createElement("button",{onClick:()=>setWoPaused(!woPaused),className:`px-3 py-1 rounded font-bold border transition shadow-md shrink-0 flex items-center gap-1 ${woPaused?'bg-amber-800 hover:bg-amber-700 border-amber-500 text-amber-100':'bg-slate-800 hover:bg-slate-700 border-slate-700 text-slate-200'}`},woPaused?'Resume':'Pause')))),/*#__PURE__*/React.createElement("div",{className:"w-64 bg-slate-900 border-l border-slate-800/80 p-3 flex flex-col justify-between shadow-2xl oz-font-mono text-xs z-20 shrink-0"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between border-b border-slate-800 pb-2 mb-2"},/*#__PURE__*/React.createElement("span",{className:"text-xs font-bold text-white tracking-wider uppercase flex items-center gap-1.5"},/*#__PURE__*/React.createElement("i",{"data-lucide":"sliders",className:"w-3 h-3 text-cyan-400"})," I/O METERS"),/*#__PURE__*/React.createElement("span",{className:"text-[9px] bg-cyan-950 border border-cyan-800 text-cyan-300 px-1 py-0.5 rounded"},"TRUE PEAK")),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2 text-[10px] text-center mb-2"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800/80 p-1.5 rounded"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold"},"IN PEAK"),/*#__PURE__*/React.createElement("div",{id:"inPeakText",className:"text-cyan-400 font-bold oz-font-mono"},"-inf dB")),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800/80 p-1.5 rounded"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold"},"OUT PEAK"),/*#__PURE__*/React.createElement("div",{id:"outPeakText",className:"text-emerald-400 font-bold oz-font-mono"},"-inf dB"))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-2 text-[10px] text-center mb-2"},/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800/80 p-1.5 rounded"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold"},"OUT RMS"),/*#__PURE__*/React.createElement("div",{id:"outRmsText",className:"text-sky-300 font-bold oz-font-mono"},"-inf")),/*#__PURE__*/React.createElement("div",{className:"bg-slate-950 border border-slate-800/80 p-1.5 rounded"},/*#__PURE__*/React.createElement("div",{className:"text-slate-500 font-bold"},"LOUDNESS"),/*#__PURE__*/React.createElement("div",{id:"lufsText",className:"text-fuchsia-300 font-bold oz-font-mono"},"--.-"))),/*#__PURE__*/React.createElement("div",{className:"flex-1 bg-slate-950 border border-slate-800/90 rounded-xl p-2 flex justify-around relative overflow-hidden my-1"},/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500 mb-1"},"IN"),/*#__PURE__*/React.createElement("div",{className:"flex-1 w-5 bg-slate-900 border border-slate-800 rounded relative overflow-hidden flex items-end"},/*#__PURE__*/React.createElement("canvas",{ref:inMeterCanvasRef,className:"w-full h-full block"}))),/*#__PURE__*/React.createElement("div",{className:"flex flex-col items-center h-full"},/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-slate-500 mb-1"},"OUT"),/*#__PURE__*/React.createElement("div",{className:"flex-1 w-6 bg-slate-900 border border-slate-800 rounded relative overflow-hidden flex items-end"},/*#__PURE__*/React.createElement("canvas",{ref:outMeterCanvasRef,className:"w-full h-full block"})))),/*#__PURE__*/React.createElement("div",{className:"grid grid-cols-2 gap-1.5 mt-2"},/*#__PURE__*/React.createElement("button",{onClick:()=>setOzState(prev=>({...prev,isBypassed:!prev.isBypassed})),className:`py-1.5 rounded text-[10px] font-bold border transition-colors ${ozState.isBypassed?'bg-cyan-600 border-cyan-500 text-white':'bg-slate-800 hover:bg-slate-700 text-slate-200 border-slate-700'}`},"Bypass"),/*#__PURE__*/React.createElement("button",{className:"bg-slate-800 hover:bg-slate-700 text-slate-200 py-1.5 rounded text-[10px] font-bold border border-slate-700 transition-colors"},"Gain Match"),/*#__PURE__*/React.createElement("button",{className:"bg-slate-800 hover:bg-slate-700 text-slate-300 py-1.5 rounded text-[10px] border border-slate-700"},"Codec"),/*#__PURE__*/React.createElement("button",{className:"bg-slate-800 hover:bg-slate-700 text-slate-300 py-1.5 rounded text-[10px] border border-slate-700"},"Dither"))))),addModuleOpen&&/*#__PURE__*/React.createElement("div",{className:"fixed inset-0 z-[110] 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 oz-font-mono"},"TH\xCAM MODULE V\xC0O MASTER 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"},[{t:'eqpro',c:'text-teal-400',i:'chart-area',d:'Parametric / Graphic EQ PRO — Pro-Q style, 8 bands, interactive canvas + spectrum.'},{t:'compressor',c:'text-amber-400',i:'compress',d:'Nén dynamic range, glue & punch cho master.'},{t:'limiter',c:'text-rose-400',i:'shield-half',d:'True-Peak ceiling (ratio 20:1, knee 0) chống clipping.'},{t:'exciter',c:'text-purple-400',i:'wand-2',d:'Saturation hài cho warmth và top-end brilliance.'},{t:'rebalance',c:'text-sky-400',i:'sliders-horizontal',d:'Cân bằng Mid/Side (Vocal/Bass vs stereo width).'},{t:'eq',c:'text-cyan-400',i:'activity',d:'EQ 4-band (lowshelf, 2× peaking, highshelf) + presets.'},{t:'imager',c:'text-fuchsia-400',i:'radio',d:'Stereo width 4-band M/S + vectorscope/correlation.'},{t:'maximizer',c:'text-emerald-400',i:'gauge',d:'Maximizer: boost, soft clip, upward comp, ceiling.'}].map(m=>/*#__PURE__*/React.createElement("button",{key:m.t,onClick:()=>addModuleToChain(m.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 ${m.c} flex items-center gap-1.5`},/*#__PURE__*/React.createElement("i",{"data-lucide":m.i,className:"w-3.5 h-3.5"})," ",MODULE_META[m.t].name),/*#__PURE__*/React.createElement("div",{className:"text-[10px] text-slate-400"},m.d)))),/*#__PURE__*/React.createElement("div",{className:"border-t border-slate-800 pt-3"},/*#__PURE__*/React.createElement("div",{className:"text-[10px] font-bold text-teal-400 uppercase tracking-wider oz-font-mono mb-2"},"VST FX (Ozone...)"),/*#__PURE__*/React.createElement("button",{onClick:()=>setMasterVstPickerOpen(true),className:"w-full bg-slate-950 hover:bg-slate-800 border border-dashed border-slate-700 hover:border-teal-600 text-teal-300 rounded px-2 py-2 text-[11px] text-left transition-colors",title:"Plugin Picker theo category (EQ / Reverb / Comp / Utility)"},/*#__PURE__*/React.createElement("i",{"data-lucide":"plug",className:"w-3 h-3 inline-block mr-1"})," Ch\u1ECDn VST FX t\u1EEB th\u01B0 vi\u1EC7n \u2014 Picker: EQ / Reverb / Comp / Utility")))),/*#__PURE__*/React.createElement(VstPluginPicker,{open:masterVstPickerOpen,onClose:()=>setMasterVstPickerOpen(false),plugins:masterFxList,loading:masterFxLoading,onPick:f=>addVstToChain(f.path)}));};// ── Media Explorer Panel (from md/51_MEDIA_EXPLORER.md) ── const MEDIA_LIBRARY_SAMPLES=[{name:"MIDI_Loop_01.mid",events:95,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_02_Bass.mid",events:48,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_03_Lead.mid",events:110,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_04.mid",events:76,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_05_Bass.mid",events:52,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"},{name:"MIDI_Loop_06.mid",events:88,lengthQn:16,time:"0:08.000",tpqn:480,bpm:130,kind:"midi"}];const MediaExplorerPanel=({height,clipboardRef,active})=>{const[userFiles,setUserFiles]=React.useState([]);const[folder,setFolder]=React.useState('library');const[selected,setSelected]=React.useState(null);const[filterText,setFilterText]=React.useState('');const[viewMode,setViewMode]=React.useState('details');const[isPlaying,setIsPlaying]=React.useState(false);const[isPaused,setIsPaused]=React.useState(false);const[isLooping,setIsLooping]=React.useState(false);const[autoPlay,setAutoPlay]=React.useState(true);const[pitch,setPitch]=React.useState(0.0);const[rate,setRate]=React.useState(1.0);const[volumeDb,setVolumeDb]=React.useState(0.0);const[currentTime,setCurrentTime]=React.useState(0);const[peaks,setPeaks]=React.useState(null);const[audioBuffer,setAudioBuffer]=React.useState(null);const[audioDuration,setAudioDuration]=React.useState(0);const[midiNotes,setMidiNotes]=React.useState(null);const[midiTotal,setMidiTotal]=React.useState(4);const[midiBars,setMidiBars]=React.useState(1);const[midiTotalBeats,setMidiTotalBeats]=React.useState(16);const[midiFileBpm,setMidiFileBpm]=React.useState(120);const[tempo,setTempo]=React.useState(function(){var saved=localStorage.getItem('studio_media_explorer_tempo');return saved?parseInt(saved):120;});const[tempoText,setTempoText]=React.useState(String(function(){var saved=localStorage.getItem('studio_media_explorer_tempo');return saved?parseInt(saved):120;}()));const[zoom,setZoom]=React.useState(1.0);// Đồng bộ zoom ra window ref — serializeProjectToSchema lưu vào metadata.zoom // (module-level không truy cập state) → restore/open khôi phục đúng zoom. React.useEffect(()=>{window.__currentZoom=zoom;},[zoom]);const[scrollOffset,setScrollOffset]=React.useState(0);const scrollOffsetRef=React.useRef(0);scrollOffsetRef.current=scrollOffset;const[selStart,setSelStart]=React.useState(null);const[selEnd,setSelEnd]=React.useState(null);const[isDragging,setIsDragging]=React.useState(false);const[previewCtxMenu,setPreviewCtxMenu]=React.useState(null);// { x, y } @@ -1586,7 +1555,8 @@ const route=createMasteringRoute(context,track,masterBus);analyserNode.connect(r // re-route THIS track's chain without touching other tracks or the bus. const fxEntry=context.createGain();const fxLegacyIn=context.createGain();gainNode.connect(fxEntry);const fxChain=track.fxChain||[];// FX Power (nút PWR): ON = mọi items con (midi/section/audioclip) qua FX // Rack Panel chain; OFF = bỏ toàn bộ track FX (fxChain + chorus/reverb). -const fxEnabled=track.fxActive!==false;const chainMods=fxEnabled?fxChain.filter(m=>m&&m.active!==false).map(m=>{try{return createTrackFxModule(m.type||m,context,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=context.createGain();fxLegacyIn.connect(fxInput);const chorus=createChorusNode(context,fxInput,pannerNode);fxStopFn=chorus.stop;}else if(fxEnabled&&track.fxType==='reverb'){const fxInput=context.createGain();fxLegacyIn.connect(fxInput);createReverbNode(context,fxInput,pannerNode);fxStopFn=null;}else{fxLegacyIn.connect(pannerNode);}// Independent MIDI/soundfont FX path (user request): SF audio flows +const fxEnabled=track.fxActive!==false;// Phase 4.3: track builtin DSP chạy trong bridge — WebAudio pass-through. +fxEntry.connect(fxLegacyIn);if(fxEnabled&&track.fxType==='chorus'){const fxInput=context.createGain();fxLegacyIn.connect(fxInput);const chorus=createChorusNode(context,fxInput,pannerNode);fxStopFn=chorus.stop;}else if(fxEnabled&&track.fxType==='reverb'){const fxInput=context.createGain();fxLegacyIn.connect(fxInput);createReverbNode(context,fxInput,pannerNode);fxStopFn=null;}else{fxLegacyIn.connect(pannerNode);}// Independent MIDI/soundfont FX path (user request): SF audio flows // gainNode → sfEntry → [own module instances] → sfOut → pan → master bus, // fully separate from the audio-clips chain — so the A (audio) bypass // NEVER affects the soundfont instrument, and the ♪ bypass never touches @@ -1603,21 +1573,21 @@ sfEntry.gain.value=1;sfOut.connect(sfPan);// Feed the soundfont into the shared sfOut.connect(scopeSplitter);// ♪ button: bypass Mastering FX Chain for the soundfont ONLY (track FX // Rack modules are still applied — PWR controls those). sfRouteGain → // masterBus.input (mastering), sfDryGain → dry bus (skip mastering). -const sfBypass=effMidiBypass(track);sfRouteGain=context.createGain();sfDryGain=context.createGain();sfRouteGain.gain.value=sfBypass?0:1;sfDryGain.gain.value=sfBypass?1:0;console.log('[Bypass] sfEntry created track',track.id,'midiBypass='+sfBypass,sfBypass?'DRY':'MASTERING','| midiItems:',(track.midiItems||[]).length);sfPan.connect(sfRouteGain);sfPan.connect(sfDryGain);sfRouteGain.connect(masterBus.input);sfDryGain.connect(masterBus.dryInput);sfOut.connect(sfAnalyser);const sfModsBuilt=fxEnabled?fxChain.filter(m=>m&&m.active!==false).map(m=>{try{return createTrackFxModule(m.type||m,context,m.params);}catch(e){return null;}}).filter(Boolean):[];sfMods=sfModsBuilt;let sfTail=sfEntry;sfMods.forEach(mod=>{sfTail.connect(mod.input);sfTail=mod.output;});sfTail.connect(sfOut);}node={gainNode,pannerNode,fxStopFn,analyserNode,route,fxEntry,fxLegacyIn,scopeAnalyserL,scopeAnalyserR,sfEntry,sfOut,sfPan,sfAnalyser,sfRouteGain,sfDryGain,fxMods:chainMods,sfMods};// Realtime mute/solo: apply the track's current mute/solo/volume state to +const sfBypass=effMidiBypass(track);sfRouteGain=context.createGain();sfDryGain=context.createGain();sfRouteGain.gain.value=sfBypass?0:1;sfDryGain.gain.value=sfBypass?1:0;console.log('[Bypass] sfEntry created track',track.id,'midiBypass='+sfBypass,sfBypass?'DRY':'MASTERING','| midiItems:',(track.midiItems||[]).length);sfPan.connect(sfRouteGain);sfPan.connect(sfDryGain);sfRouteGain.connect(masterBus.input);sfDryGain.connect(masterBus.dryInput);sfOut.connect(sfAnalyser);sfMods=[];sfEntry.connect(sfOut);}node={gainNode,pannerNode,fxStopFn,analyserNode,route,fxEntry,fxLegacyIn,scopeAnalyserL,scopeAnalyserR,sfEntry,sfOut,sfPan,sfAnalyser,sfRouteGain,sfDryGain,fxMods:[],sfMods:[]};// Realtime mute/solo: apply the track's current mute/solo/volume state to // the fresh node so items of muted/soloed tracks start correctly. const trackList=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:[track];const nodeAudibleGain=computeTrackAudibleGain(trackList,track);setTrackNodeGain(node,nodeAudibleGain);if(node.sfEntry){try{const g=typeof nodeAudibleGain==='number'&&isFinite(nodeAudibleGain)&&!isNaN(nodeAudibleGain)?nodeAudibleGain:1.0;node.sfEntry.gain.setTargetAtTime(g,context.currentTime,0.02);}catch(e){}}activeTrackNodesRef.current[track.id]=node;console.log('[Bypass] node created track',track.id,'initial audioBypass=',!!trackAudioBypassMap[track.id],'routeGain=',node.route.routeGain.gain.value,'dryGain=',node.route.dryGain.gain.value);updateSfRouting();}return node.gainNode;};// Per-context audio graph reconstruction (unified_fx_rack_panel.md §III.2): // 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,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 +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();node.fxEntry.connect(node.fxLegacyIn);node.fxMods=[];// Rebuild the independent SF chain too (same modules, separate instances) +if(node.sfEntry&&node.sfOut){node.sfEntry.disconnect();node.sfEntry.connect(node.sfOut);node.sfMods=[];}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;fxRtGuiPushShm();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];fxRtGuiPushShm();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 +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=>s.type==='vst3'?{type:'vst3',path:s.path,name:s.name||s.path,preset_b64:s.preset_b64||'',bypass:false}:{type:'builtin',id:s.type,params:s.params&&Object.keys(s.params).length?s.params:{},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:fxRtTrackChainKey(chain),paramsBySlot:chain.map(s=>fxRtEntryParams(s,null)),inQueue:[],pumpTimer:null};fxRtTracks[key]=entry;fxRtGuiPushShm();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);try{(entry.paramsBySlot||[]).forEach((pl,slot)=>pl.forEach(p=>ws.send(JSON.stringify({cmd:'set_param',slot,key:p.key,value:p.value}))));}catch(e){}};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||!entry.ws)return;if(d.type==='set_param'){try{entry.ws.send(JSON.stringify({cmd:'set_param',slot:d.slot,key:d.key,value:d.value}));}catch(e){}return;}if(d.type!=='in')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.fxEntry: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];fxRtGuiPushShm();if(sid){try{window.SonicAPI.fxRealtimeStop({session_id:sid}).catch(()=>{});}catch(e){}}};const fxRtTrackReconcile=(trackId,node,track)=>{try{const chain=fxRtTrackChain(track);const chainKey=fxRtTrackChainKey(chain);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.chainKey!==chainKey){fxRtTrackStop(trackId,p,true);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 @@ -1697,9 +1667,9 @@ const midiCacheToBuffer=trackId=>{const c=midiCacheRef.current[trackId];if(!c||! // pannerNode (audioclips + section items) mixed with sfOut (midi items). if(!node.scopeAnalyserL||!node.scopeAnalyserR)return null;return{L:node.scopeAnalyserL,R:node.scopeAnalyserR,sr:getAudioContext().sampleRate};};// UI helper: fetch a live module instance (e.g. 'eqpro') from the active track // node so interactive panels can drive its DSP params in realtime. -window.__getTrackFxModule=(trackId,type)=>{const node=activeTrackNodesRef.current[trackId];if(!node)return null;const found=(node.fxMods||[]).find(m=>m&&m.type===type);return found||null;};// Same for the soundfont (MIDI) module instances — lets the EQ PRO panel drive +window.__getTrackFxModule=(trackId,type)=>{if(type!=='eqpro')return null;const findSlot=()=>{const list=activeTracksRef.current&&activeTracksRef.current.length?activeTracksRef.current:tracks;const track=list.find(t=>t.id===trackId);return track?fxRtTrackChain(track).findIndex(m=>m&&m.type==='eqpro'):-1;};const sendAll=fn=>{const slot=findSlot();if(slot<0)return;Object.keys(fxRtTracks).forEach(k=>{if(!k.startsWith(trackId+':'))return;const e=fxRtTracks[k];if(e&&e.ws&&e.ws.readyState===1)fn(e.ws,slot);});};return{setBand:(idx,patch)=>sendAll((ws,slot)=>Object.keys(patch||{}).forEach(k=>{try{ws.send(JSON.stringify({cmd:'set_param',slot,key:'band'+idx+'_'+k,value:patch[k]}));}catch(e){}})),setAmount:v=>sendAll((ws,slot)=>{try{ws.send(JSON.stringify({cmd:'set_param',slot,key:'amount',value:v}));}catch(e){}}),syncBands:bands=>sendAll((ws,slot)=>(bands||[]).forEach((b,i)=>['freq','gain','q'].forEach(k=>{try{ws.send(JSON.stringify({cmd:'set_param',slot,key:'band'+i+'_'+k,value:b[k]}));}catch(e){}})))};};// Same for the soundfont (MIDI) module instances — lets the EQ PRO panel drive // BOTH the audio-clip chain and the SF chain in realtime. -window.__getTrackSfFxModule=(trackId,type)=>{const node=activeTrackNodesRef.current[trackId];if(!node)return null;const found=(node.sfMods||[]).find(m=>m&&m.type===type);return found||null;};const getOrCreateSubTrackNode=(track,subTrack,context)=>{if(!track||!subTrack)return null;const subKey=track.id+'_sub_'+subTrack.id;let node=activeTrackNodesRef.current[subKey];if(!node){const gainNode=context.createGain();const volDb=subTrack.volumeDb??0;const volLinear=volDb<=-50?0:Math.pow(10,volDb/20);gainNode.gain.setValueAtTime(volLinear,context.currentTime);const parentNode=getOrCreateTrackNode(track,context);// Route RIÊNG cho sub-track (nút A/♪ trên section strip): mastering path → +window.__getTrackSfFxModule=(trackId,type)=>window.__getTrackFxModule(trackId,type);const getOrCreateSubTrackNode=(track,subTrack,context)=>{if(!track||!subTrack)return null;const subKey=track.id+'_sub_'+subTrack.id;let node=activeTrackNodesRef.current[subKey];if(!node){const gainNode=context.createGain();const volDb=subTrack.volumeDb??0;const volLinear=volDb<=-50?0:Math.pow(10,volDb/20);gainNode.gain.setValueAtTime(volLinear,context.currentTime);const parentNode=getOrCreateTrackNode(track,context);// Route RIÊNG cho sub-track (nút A/♪ trên section strip): mastering path → // parent; dry path → masterBus.dryInput — bypass áp đúng track section. // Dùng map THẬT (trackAudioBypassMap/trackMidiBypassMap) — KHÔNG qua // effAudioBypass (override mastering-ON ép false → route luôn no-bypass diff --git a/app/static/js/sf-fx-realtime.js b/app/static/js/sf-fx-realtime.js index 8945297..f402248 100644 --- a/app/static/js/sf-fx-realtime.js +++ b/app/static/js/sf-fx-realtime.js @@ -18,6 +18,7 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor { this.outPos = 0; this.port.onmessage = (e) => { const d = e.data; + if (d && d.type === 'set_param') { this.port.postMessage(d); return; } 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(); diff --git a/native_bridge/src/BuiltinFxChain.cpp b/native_bridge/src/BuiltinFxChain.cpp index 151993c..f5e4858 100644 --- a/native_bridge/src/BuiltinFxChain.cpp +++ b/native_bridge/src/BuiltinFxChain.cpp @@ -205,6 +205,23 @@ public: } bool setParam(const std::string& key, double value) override { if (key == "amount") { amount_ = value / 100.0; rebuild(); return true; } + if (key.compare(0, 4, "band") == 0) { + const size_t us = key.find('_', 4); + if (us != std::string::npos && us > 4) { + const int idx = std::atoi(key.c_str() + 4); + const std::string field = key.substr(us + 1); + if (idx >= 0 && idx < (int)bands_.size()) { + Band& b = bands_[idx]; + if (field == "freq") b.freq = value; + else if (field == "gain") b.gain = value; + else if (field == "q") b.q = value; + else if (field == "active") b.active = (value != 0.0); + else return false; + rebuild(); + return true; + } + } + } return false; } private: diff --git a/native_bridge/src/RenderFxJob.cpp b/native_bridge/src/RenderFxJob.cpp index dc1c45e..ab3cd1f 100644 --- a/native_bridge/src/RenderFxJob.cpp +++ b/native_bridge/src/RenderFxJob.cpp @@ -1144,6 +1144,12 @@ struct RealtimeFxChain::Impl { }; struct Chain { std::vector entries; }; + // SET_PARAM chờ chain swap (setParam tới trước setChain): worker áp sau + // khi swap — không mất param race lúc khởi động. + struct PendingParam { int slot; std::string key; double value; }; + std::mutex pmutex_; // guards pending_ + std::vector pending_; + std::mutex mutex_; // guards chain_ / retired_ std::shared_ptr chain_; std::shared_ptr retired_; // dtor deferred to the worker thread @@ -1224,9 +1230,14 @@ void RealtimeFxChain::setParam(int slot, const std::string& key, double value) { if (!impl_) return; std::shared_ptr c; { std::lock_guard lk(impl_->mutex_); c = impl_->chain_; } - if (!c || slot < 0 || (size_t)slot >= c->entries.size()) return; - auto& e = c->entries[(size_t)slot]; - if (e.bfx) e.bfx->setParam(key, value); + if (c && slot >= 0 && (size_t)slot < c->entries.size()) { + auto& e = c->entries[(size_t)slot]; + if (e.bfx) e.bfx->setParam(key, value); + return; + } + // Chain chưa swap (SET_PARAM tới trước setChain) — stash, worker áp sau swap. + std::lock_guard lk(impl_->pmutex_); + impl_->pending_.push_back({slot, key, value}); } uint64_t RealtimeFxChain::chainGen() { @@ -1325,6 +1336,16 @@ void RealtimeFxChain::Impl::workerLoop() { retired_ = std::move(chain_); // old chain destroyed on THIS thread chain_ = std::move(next); } + { + // Áp param chờ (đăng ký trước khi chain tồn tại). + std::lock_guard lk(pmutex_); + for (const auto& p : pending_) { + if (p.slot < 0 || (size_t)p.slot >= chain_->entries.size()) continue; + auto& e = chain_->entries[(size_t)p.slot]; + if (e.bfx) e.bfx->setParam(p.key, p.value); + } + pending_.clear(); + } gen_.fetch_add(1, std::memory_order_release); // loop báo lại latency } #ifdef _WIN32 diff --git a/tests/test_daw_phase5.py b/tests/test_daw_phase5.py new file mode 100644 index 0000000..675ef7e --- /dev/null +++ b/tests/test_daw_phase5.py @@ -0,0 +1,431 @@ +# -*- coding: utf-8 -*- +"""Phase 5 verify — TASKS_DAW_A.md §39-47 (chuẩn DAW checklist). + +5.3 Golden live=export: realtime SHM loop vs offline --render-fx cùng chain. +5.4 Order: chain [eq, vst3-delay, limiter] giữ thứ tự (contract) + impulse + qua adelay (VST delay áp giữa chain, không gom cuối, không bỏ). +5.5 Fader: master chain active + fader -6dB → đỉnh giảm 2x, chain vẫn áp. +5.6 PDC: slot latency_samples=1024 + audio bị delay 1024 → bù track → xuyên + pha hết (sum = 2×B); control không PDC → lệch pha. +5.7 Smoke: /capabilities + render master imager+maximizer+vst3. + +Skip nếu thiếu fx_vst_bridge.exe (native_bridge/build/Release) — ngoại trừ +các test contract thuần (không spawn bridge). +""" +import io +import json +import math +import os +import subprocess +import sys +import tempfile +import time +import wave + +import numpy as np +import pytest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) + +FX_BRIDGE = os.path.join(REPO, "native_bridge", "build", "Release", "fx_vst_bridge.exe") +DAW_BRIDGE = os.path.join(REPO, "native_bridge", "build", "Release", "daw_vst_bridge.exe") +ADELAY = os.path.join(REPO, "native_bridge", "build", "VST3", "Release", "adelay.vst3") +SR = 44100 + + +@pytest.fixture(scope="module") +def fx_bridge(): + if not os.path.isfile(FX_BRIDGE): + pytest.skip("fx_vst_bridge.exe chưa build (native_bridge/build/Release)") + return FX_BRIDGE + + +@pytest.fixture(scope="module") +def adelay(): + if not os.path.isfile(os.path.join(ADELAY, "Contents", "x86_64-win", "adelay.vst3")): + pytest.skip("adelay.vst3 sample chưa build (native_bridge/build/VST3/Release)") + return ADELAY + + +def _write_wav(path, data, sr): + pcm = (np.clip(data, -1.0, 1.0).T * 32767).astype(np.int16) + with wave.open(path, "wb") as w: + w.setnchannels(2) + w.setsampwidth(2) + w.setframerate(sr) + w.writeframes(pcm.tobytes()) + + +def _read_wav(path): + import soundfile as sf + data, sr = sf.read(path, dtype="float32", always_2d=True) + return data.T, sr + + +def _render_bridge(bridge, wav_in, sr, chain, block=256): + job = {"sample_rate": sr, "block_size": block, "fx_chain": chain} + with tempfile.TemporaryDirectory() as td: + job_path = os.path.join(td, "job.json") + out_path = os.path.join(td, "out.wav") + with io.open(job_path, "w", encoding="utf-8") as f: + json.dump(job, f) + r = subprocess.run([bridge, "--render-fx", job_path, "--in", wav_in, + "--out", out_path], + capture_output=True, text=True, timeout=180) + assert r.returncode == 0, f"bridge rc={r.returncode}: {r.stdout[-500:]} {r.stderr[-500:]}" + assert os.path.isfile(out_path), f"no out wav: {r.stdout[-500:]} {r.stderr[-500:]}" + return _read_wav(out_path)[0] + + +def _snr_db(ref, got): + ref = ref.astype(np.float64) + got = got.astype(np.float64) + n = min(ref.shape[1], got.shape[1]) + if n == 0: + return -float("inf") + ref, got = ref[:, :n], got[:, :n] + denom = float(np.sum((ref - got) ** 2)) + if denom < 1e-12: + return float("inf") + return 10.0 * math.log10(float(np.sum(ref ** 2)) / denom) + + +def _find_delay(live, ref, max_lag=2048): + a = live[0].astype(np.float64) + b = ref[0].astype(np.float64) + n = min(a.shape[0], b.shape[0]) + a, b = a[:n], b[:n] + corr = np.correlate(a, b, mode="same") + lag = int(np.argmax(np.abs(corr))) - n // 2 + if abs(lag) > max_lag: + return 0 + return lag + + +def _midi_track(): + return { + "id": "t1", + "type": "MIDI", + "instrument_id": "__no_such_plugin__", + "instrument_source": "vsti", + "fx_type": None, + "volume_db": 0.0, + "pan": 0.0, + "mute": False, + "items": [{ + "type": "MIDI_ITEM", + "start_bar": 0.0, + "duration_bars": 1.0, + "clip_start_offset_bars": 0.0, + "source_data": {"notes": [ + {"pitch": 60, "velocity": 0.8, "start_beat": 0.0, "duration_beats": 1.0} + ]}, + }], + } + + +def _side_rms(path): + import soundfile as sf + data, _ = sf.read(path, dtype="float32", always_2d=True) + side = data[:, 0] - data[:, 1] + return float(np.sqrt(np.mean(side ** 2))) + + +# ── 5.3 Golden live=export ──────────────────────────────────────────────── +def test_53_live_equals_export(fx_bridge, monkeypatch): + """Realtime SHM loop vs offline --render-fx, cùng chain builtin, block 256 + → SNR ≥ 30 dB (cùng engine C++, live = export).""" + monkeypatch.setenv("SF_FX_BRIDGE_PATH", fx_bridge) + from app.core import fx_realtime + + chain = [ + {"type": "builtin", "id": "eq", + "params": {"g1": 3.0, "g2": -2.0, "g3": 1.0, "g4": 0.5}}, + {"type": "builtin", "id": "compressor", + "params": {"threshold": -18.0, "ratio": 3.0, "makeup": 2.0}}, + {"type": "builtin", "id": "limiter", "params": {"ceiling": -3.0}}, + ] + n = 172 * 256 # bội của block 256 + t = np.arange(n) / SR + rng = np.random.default_rng(7) + sig = (0.2 * np.sin(2 * np.pi * 220 * t) + 0.1 * np.sin(2 * np.pi * 1100 * t) + + 0.02 * rng.standard_normal(n)) + audio = np.stack([sig, 0.9 * sig]).astype(np.float32) + + with tempfile.TemporaryDirectory() as td: + wav_in = os.path.join(td, "in.wav") + _write_wav(wav_in, audio, SR) + offline = _render_bridge(fx_bridge, wav_in, SR, chain, block=256) + + sess = fx_realtime.start_session(chain, SR) + try: + out = bytearray() + nb = n // 256 + for i in range(nb): + blk = audio[:, i * 256:(i + 1) * 256] + inter = np.empty(512, dtype=np.float32) + inter[0::2] = blk[0] + inter[1::2] = blk[1] + sess.write_input(inter.tobytes()) + time.sleep(0.006) + for b in sess.read_output(): + out += b + time.sleep(0.05) + for b in sess.read_output(): + out += b + finally: + sess.close() + arr = np.frombuffer(bytes(out), dtype=np.float32) + assert arr.size > 0, "realtime loop không ra output" + live = arr.reshape(-1, 2).T + assert live.shape[1] >= n // 2, f"live quá ngắn: {live.shape}" + + lag = _find_delay(live, offline) + if lag >= 0: + got = live[:, lag:lag + offline.shape[1]] + else: + got = live[:, :offline.shape[1] + lag] + n_cmp = min(offline.shape[1], got.shape[1]) - 8 + assert n_cmp > 1000, "đoạn so sánh quá ngắn" + snr = _snr_db(offline[:, :n_cmp], got[:, :n_cmp]) + assert snr >= 30.0, f"live≠export SNR {snr:.1f} dB (lag {lag})" + + +# ── 5.4 Order ───────────────────────────────────────────────────────────── +def test_54_chain_order_contract(): + """Chain [eq, vst3-delay, limiter] giữ NGUYÊN thứ tự qua merge offline + + live JSON — không gom VST cuối (regression cho "không gom cuối").""" + from app.core import fx_realtime + from app.core.render_engine import _merge_track_fx_chain + + track = { + "id": "t1", + "fx_chain": [ + {"type": "builtin", "id": "eq", "params": {"g1": 3.0}}, + {"type": "vst3", "path": ADELAY, "active": True}, + {"type": "builtin", "id": "limiter", "params": {"ceiling": -3.0}}, + ], + "vst_fx_chain": [], + "vst_fx_bypass": False, + } + merged = _merge_track_fx_chain(track) + assert [s.get("id") or s.get("type") for s in merged] == ["eq", "vst3", "limiter"] + + s = object.__new__(fx_realtime.FxRealtimeSession) + s.chain = list(merged) + s._lock = None + out = s._chain_json_for_bridge() + assert [o["type"] for o in out] == ["builtin", "vst3", "builtin"] + assert out[1]["path"] == ADELAY + + +def test_54_vst3_delay_in_middle_audio(fx_bridge, adelay): + """Impulse qua [eq, adelay(default 1s), limiter] → peak tại ≈44100, im lặng + trước đó; SNR vs reference Python (eq → shift 44100 → limiter) ≥ 18 dB — + (EQ Python vs C++ impulse response lệch ~22 dB; check order thật là vị trí + peak + pre-silence) — VST delay áp giữa chain (sau EQ, trước limiter).""" + from app.core.render_engine import _apply_builtin_fx_chain + + chain = [ + {"type": "builtin", "id": "eq", + "params": {"g1": 3.0, "g2": -2.0, "g3": 1.0, "g4": 0.5}}, + {"type": "vst3", "path": adelay}, + {"type": "builtin", "id": "limiter", "params": {"ceiling": -3.0}}, + ] + n = int(SR * 1.5) + audio = np.zeros((2, n), dtype=np.float32) + audio[0, 0] = 1.0 + audio[1, 0] = 1.0 + with tempfile.TemporaryDirectory() as td: + wav_in = os.path.join(td, "in.wav") + _write_wav(wav_in, audio, SR) + got = _render_bridge(fx_bridge, wav_in, SR, chain, block=256) + + idx = int(np.unravel_index(np.argmax(np.abs(got)), got.shape)[1]) + assert abs(idx - SR) < 200, f"peak tại {idx} — mong đợi ~{SR} (delay 1s)" + assert float(np.max(np.abs(got[:, :int(SR * 0.9)]))) == 0.0, "có tín hiệu trước delay" + + ref = _apply_builtin_fx_chain( + audio.copy(), + [{"type": "eq", "params": {"g1": 3.0, "g2": -2.0, "g3": 1.0, "g4": 0.5}}], SR) + ref = np.roll(ref, SR, axis=1) + ref = _apply_builtin_fx_chain(ref, [{"type": "limiter", "params": {"ceiling": -3.0}}], SR) + n_cmp = min(ref.shape[1], got.shape[1]) - 8 + snr = _snr_db(ref[:, :n_cmp], got[:, :n_cmp]) + assert snr >= 18.0, f"SNR {snr:.1f} dB" + + +# ── 5.5 Fader sau master chain ──────────────────────────────────────────── +def test_55_fader_after_master_chain(monkeypatch, tmp_path, fx_bridge): + """Master chain active (gain +6dB) + fader -6dB → đỉnh giảm 2x, chain vẫn + áp (đỉnh fader-6 ≈ base, đỉnh fader0 ≈ 2×base) — fader SAU chain.""" + monkeypatch.setenv("SF_FX_BRIDGE_PATH", fx_bridge) + if not os.path.isfile(DAW_BRIDGE): + pytest.skip("daw_vst_bridge.exe chưa build") + monkeypatch.setenv("SF_BRIDGE_PATH", DAW_BRIDGE) + from app.api.v1 import plugins as pl + monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge") + from app.core.render_engine import PythonRenderEngine + engine = PythonRenderEngine() + + def project(master_vol, chain): + track = _midi_track() + track["volume_db"] = -20.0 # tránh normalize (>1.0) + return { + "metadata": {"bpm": 120.0, "time_signature_numerator": 4}, + "main_session": { + "length_bars": 1.0, + "tracks": [track], + "master": { + "fx_chain": chain, + "bypass": False, + "volume_db": master_vol, + }, + }, + "section_store": {}, + } + + GAIN = [{"type": "builtin", "id": "gain", "params": {"db": 6.0}}] + + def peak(name): + chain = {"base.wav": [], "0.wav": GAIN, "6.wav": GAIN}[name] + vol = {"base.wav": 0.0, "0.wav": 0.0, "6.wav": -6.0}[name] + out = str(tmp_path / name) + engine.render_project(project(vol, chain), out) + data, _ = _read_wav(out) + return float(np.max(np.abs(data))) + + base = peak("base.wav") + p0 = peak("0.wav") + p6 = peak("6.wav") + assert p0 > 1.7 * base, f"chain +6dB không áp: {p0:.4f} vs base {base:.4f}" + assert 0.4 < p6 / p0 < 0.6, f"fader -6dB phải ~1/2 đỉnh: {p6:.4f}/{p0:.4f}" + assert 0.8 < p6 / base < 1.2, f"fader-6 phải ≈ base: {p6:.4f} vs {base:.4f}" + + +# ── 5.6 PDC ─────────────────────────────────────────────────────────────── +def test_56_pdc_aligns_tracks(monkeypatch, tmp_path, fx_bridge): + """Plugin latency 1024 (slot latency_samples) + audio thật bị delay 1024 → + PDC bù track ngắn → sum = 2×B (xuyên pha hết). Control latency=0 → sum + lệch pha (SNR thấp).""" + monkeypatch.setenv("SF_FX_BRIDGE_PATH", fx_bridge) + from app.api.v1 import plugins as pl + monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge") + from app.core.render_engine import PythonRenderEngine + engine = PythonRenderEngine() + + n = SR * 2 # 1 bar @120bpm 4/4 = 88200 + t = np.arange(n) / SR + w = (0.2 * np.sin(2 * np.pi * 220 * t) + 0.1 * np.sin(2 * np.pi * 1100 * t)).astype(np.float32) + ws = np.zeros_like(w) + ws[1024:] = w[:n - 1024] + fa = str(tmp_path / "a.wav") + fb = str(tmp_path / "b.wav") + _write_wav(fa, np.stack([w, w]), SR) + _write_wav(fb, np.stack([ws, ws]), SR) + + def project(lat): + def track(path, chain): + return { + "id": path, + "type": "AUDIO", + "volume_db": 0.0, + "pan": 0.0, + "fx_chain": chain, + "vst_fx_chain": [], + "vst_fx_bypass": False, + "items": [{ + "type": "AUDIO_ITEM", + "start_bar": 0.0, + "duration_bars": 1.0, + "clip_start_offset_bars": 0.0, + "source_data": {"audio_file_url": path}, + }], + } + chain_b = [] + if lat: + # bypass passthrough — chỉ khai latency cho PDC (spec §3 field) + chain_b = [{"type": "vst3", "path": "", "bypass": True, + "active": True, "latency_samples": lat}] + return { + "metadata": {"bpm": 120.0, "time_signature_numerator": 4}, + "main_session": { + "length_bars": 1.0, + "tracks": [track(fa, []), track(fb, chain_b)], + }, + "section_store": {}, + } + + out_pdc = str(tmp_path / "pdc.wav") + out_nopdc = str(tmp_path / "nopdc.wav") + engine.render_project(project(1024), out_pdc) + engine.render_project(project(0), out_nopdc) + d_pdc, _ = _read_wav(out_pdc) + d_nopdc, _ = _read_wav(out_nopdc) + + expected = np.stack([ws, ws]) * 2.0 # PDC delay track A → cùng pha B + n_cmp = n - 16 + snr_pdc = _snr_db(expected[:, :n_cmp], d_pdc[:, :n_cmp]) + snr_nopdc = _snr_db(expected[:, :n_cmp], d_nopdc[:, :n_cmp]) + assert snr_pdc >= 30.0, f"PDC sum lệch: SNR {snr_pdc:.1f} dB" + assert snr_nopdc < 15.0, f"control không lệch pha: SNR {snr_nopdc:.1f} dB" + + +# ── 5.7 Smoke ───────────────────────────────────────────────────────────── +def test_57_capabilities_smoke(): + """Server /capabilities phản hồi OK (smoke API).""" + from fastapi.testclient import TestClient + from app.main import app + r = TestClient(app).get("/api/v1/system/capabilities") + assert r.status_code == 200, r.text[:300] + assert isinstance(r.json(), dict) + + +def test_57_master_chain_imager_maximizer_vst3(monkeypatch, tmp_path, fx_bridge, adelay): + """Render master imager+maximizer+vst3 (adelay) qua bridge — không crash, + non-silent; imager vẫn tác dụng khi chain có vst3.""" + monkeypatch.setenv("SF_FX_BRIDGE_PATH", fx_bridge) + if not os.path.isfile(DAW_BRIDGE): + pytest.skip("daw_vst_bridge.exe chưa build") + monkeypatch.setenv("SF_BRIDGE_PATH", DAW_BRIDGE) + from app.api.v1 import plugins as pl + monkeypatch.setattr(pl.settings, "RENDER_ENGINE", "bridge") + from app.core.render_engine import PythonRenderEngine + engine = PythonRenderEngine() + + def render(chain, name): + track = _midi_track() + track["volume_db"] = -20.0 + track["pan"] = 0.6 + proj = { + "metadata": {"bpm": 120.0, "time_signature_numerator": 4}, + "main_session": {"length_bars": 1.0, "tracks": [track]}, + "section_store": {}, + } + if chain is not None: + proj["mastering_settings"] = { + "chain": chain, + "w1": 0, "w2": 0, "w3": 0, "w4": 0, + "maxGain": 0.0, "maxSoftClip": False, "maxUpward": False, + "ceiling": -1.0, + } + out = str(tmp_path / name) + engine.render_project(proj, out) + return out + + base = _side_rms(render(None, "base.wav")) + s0 = _side_rms(render([ + {"id": "imager", "type": "imager", "name": "Imager", "active": True}, + {"type": "vst3", "path": adelay}, + {"id": "maximizer", "type": "maximizer", "name": "Max", "active": True}, + ], "im0.wav")) + s2 = _side_rms(render([ + {"id": "imager", "type": "imager", "name": "Imager", "active": True, + "params": {"w1": 200, "w2": 200, "w3": 200, "w4": 200}}, + {"type": "vst3", "path": adelay}, + {"id": "maximizer", "type": "maximizer", "name": "Max", "active": True}, + ], "im200.wav")) + assert base > 0.001, "pan tạo side content" + assert s0 < 0.05 * base, f"imager w=0 không giết side: {s0:.5f} vs {base:.5f}" + assert s2 > 1.5 * base, f"imager w=200 không tăng side: {s2:.5f} vs {base:.5f}"