Phase 4+5: WebAudio I/O shim + verify DAW checklist — live=export, order, fader, PDC, smoke

This commit is contained in:
2026-08-22 21:02:52 +07:00
parent e5c957fc87
commit c3aba40ffe
9 changed files with 756 additions and 388 deletions
+13 -13
View File
@@ -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)
+2
View File
@@ -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"):
+5 -1
View File
@@ -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
+224 -302
View File
@@ -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'}`}>
<div className="flex items-center gap-2 min-w-0">
@@ -12645,17 +12559,17 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
</div>
);
}
const s = u.s;
const s = u.m;
return (
<div key={`v${u.i}`}
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; }}
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'}`}>
<div className="flex items-center gap-2 min-w-0">
<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' }}>
<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' }}>
<i data-lucide="power" className="w-2.5 h-2.5"></i>
</button>
<div className="min-w-0">
@@ -12663,7 +12577,7 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
<div className="text-[9px] font-mono truncate text-teal-500/80">{u.i + 1}. {s.preset_b64 ? '✓ preset' : 'no preset'}</div>
</div>
</div>
<button onClick={e => { e.stopPropagation(); removeVst(u.i); }} className="text-slate-600 hover:text-red-400 text-xs px-0.5 shrink-0" title="Xóa slot">
<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óa slot">
<i data-lucide="x" className="w-3 h-3"></i>
</button>
</div>
@@ -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] })); });
});
}
});
}}
/>;
})()}
</div>
@@ -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;
File diff suppressed because one or more lines are too long
+1
View File
@@ -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();
+17
View File
@@ -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:
+24 -3
View File
@@ -1144,6 +1144,12 @@ struct RealtimeFxChain::Impl {
};
struct Chain { std::vector<Entry> 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<PendingParam> pending_;
std::mutex mutex_; // guards chain_ / retired_
std::shared_ptr<Chain> chain_;
std::shared_ptr<Chain> 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<Impl::Chain> c;
{ std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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
+431
View File
@@ -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}"