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'}`}>
-
-
{ e.stopPropagation(); removeVst(u.i); }} className="text-slate-600 hover:text-red-400 text-xs px-0.5 shrink-0" title="Xóa slot">
+ { e.stopPropagation(); removeVst(vstChain.indexOf(s)); }} className="text-slate-600 hover:text-red-400 text-xs px-0.5 shrink-0" title="Xóa slot">
@@ -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}"