fix(fx-rt): het crackle master FX - limiter attack smooth + crossfade khi bat/tat chain
- NativeInstrumentEngine: kAttack 0.9->0.1 (het zipper khi sum nhieu track) - app.jsx: crossfade masterFxDryGain/masterFxGain thay hard-rewire graph luc activation/deactivation master chain FX; worklet bao 'started' -> ramp dung luc; PDC tre sau khi dry silent (tranh gap 80ms) - sf-fx-realtime.js: fade-in 512 mau luc FILL xong / resume sau underrun - index.html + addModule: cache-buster v=202608241200 - rebuild app.precompiled.js, copy bundle vao dist/ + src-tauri/resources/ - keo dai fix cac round truoc: FILL 16, FXRT_BURST_MS, REBUFFER, pacing, VST GUI click/focus, solo crackle - them harness + plans/patch scripts
This commit is contained in:
+263
-72
@@ -1054,6 +1054,16 @@ function initMasterBus(ctx) {
|
||||
const output = ctx.createGain();
|
||||
output.gain.value = 1.0;
|
||||
|
||||
// Crossfade master-FX activation (het click khi bat/tat master chain FX):
|
||||
// path mac dinh outputAnalyser -> masterFxDryGain(1) -> pdcDelay -> output;
|
||||
// nhanh FX noi vao masterFxGain(0) -> output. fxRtStart/fxRtStop ramp 2 gain
|
||||
// nay thay vi hard-rewire graph (dry path luon noi san — no click, no gap
|
||||
// trong luc worklet FILL ~86ms).
|
||||
const masterFxDryGain = ctx.createGain();
|
||||
masterFxDryGain.gain.value = 1.0;
|
||||
const masterFxGain = ctx.createGain();
|
||||
masterFxGain.gain.value = 0.0;
|
||||
|
||||
// Per-track mastering-bypass dry bus: tracks with bypass ON feed into
|
||||
// dryInput -> dryOutput -> output, skipping the mastering modules
|
||||
// (EQ / Imager / Maximizer) while still passing the master volume fader
|
||||
@@ -1104,6 +1114,8 @@ function initMasterBus(ctx) {
|
||||
compressor: ctx.createDynamicsCompressor(),
|
||||
analyser,
|
||||
output,
|
||||
masterFxDryGain,
|
||||
masterFxGain,
|
||||
masteringActive: false,
|
||||
dryInput,
|
||||
dryOutput,
|
||||
@@ -1151,7 +1163,10 @@ function initMasterBus(ctx) {
|
||||
// "state is bad" → CÂM + stuck. Mastering chain có comp/lim module riêng khi bật.
|
||||
masterBus.input.connect(masterBus.inputAnalyser);
|
||||
masterBus.inputAnalyser.connect(masterBus.outputAnalyser);
|
||||
masterBus.outputAnalyser.connect(masterBus.output);
|
||||
masterBus.outputAnalyser.connect(masterBus.masterFxDryGain);
|
||||
masterBus.masterFxDryGain.connect(masterBus.pdcDelay);
|
||||
masterBus.outputAnalyser.connect(masterBus.masterFxGain);
|
||||
masterBus.masterFxGain.connect(masterBus.output);
|
||||
masterBus.muteGain.connect(masterBus.analyser);
|
||||
masterBus.muteGain.connect(masterBus.splitter);
|
||||
masterBus.analyser.connect(ctx.destination);
|
||||
@@ -1631,7 +1646,13 @@ function encodeFloatWavBlob(pcm, sampleRate, channels) {
|
||||
// worklet → analyser (sau master fader, trước metering/destination — khớp thứ
|
||||
// tự offline export). Track: mỗi track 1 session/worklet splice vào CẢ 2 đường
|
||||
// chain (audio-clips + MIDI/SF) — mọi luồng track qua bridge trước main out.
|
||||
const fxRt = { active: false, sessionId: null, ws: null, node: null, inQueue: [], pumpTimer: null, chainKey: null, latencyTotal: 0 };
|
||||
const fxRt = { active: false, starting: false, startToken: 0, sessionId: null, ws: null, node: null, inQueue: [], pumpTimer: null, lastInSent: 0, chainKey: null, latencyTotal: 0, fadedIn: false };
|
||||
// Pace input realtime: worklet sản xuất đúng realtime, nhưng sau khi main
|
||||
// thread bị block (solo restart, GUI decode, rAF) các frame 'in' dồn lại —
|
||||
// gửi 1 loạt làm SHM ring 4-slot tràn → mất block → ngắt quãng. Gửi tối đa 1
|
||||
// burst (4 block ~21ms) mỗi 21ms → ring luôn kịp tiêu thụ, audio hồi phục
|
||||
// trơn sau stall (FILL hấp thụ phần đầu).
|
||||
const FXRT_BURST_MS = 21;
|
||||
// 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).
|
||||
@@ -1688,20 +1709,41 @@ function fxRtPushAllParams() {
|
||||
});
|
||||
});
|
||||
}
|
||||
async function fxRtSetChain(chain) {
|
||||
const sid = fxRt.sessionId;
|
||||
if (!sid || !fxRt.ws || fxRt.ws.readyState !== 1) 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 });
|
||||
try {
|
||||
await window.SonicAPI.fxRealtimeSetChain({ session_id: sid, fx_chain: chainPayload });
|
||||
try { fxRtPushAllParams(); } catch (e) {}
|
||||
} catch (e) {
|
||||
console.warn('[FX RT] set-chain fail:', 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);
|
||||
// Chain thay đổi (insert/remove/bypass/reorder/preset GUI) — KHÔNG restart
|
||||
// (ngắt âm + crackle): set_chain lên session ĐANG CHẠY → bridge worker
|
||||
// load chain mới (audio giữ chain cũ tới khi swap) → swap atomic mượt.
|
||||
else if (fxRt.chainKey !== ck) { fxRtSetChain(chain); fxRt.chainKey = ck; return; }
|
||||
else return;
|
||||
}
|
||||
if (!chain.length) return;
|
||||
// Token chống race bypass: user bấm PWR/Bypass khi fxRtStart đang chờ
|
||||
// fxRealtimeStart (~4s load VST). Lúc đó fxRt.active vẫn false → effect
|
||||
// `else if (fxRt.active) fxRtStop()` KHÔNG chạy → start in-flight vẫn nối
|
||||
// node → bypass vô hiệu. Mỗi fxRtStop() bump startToken → mọi checkpoint
|
||||
// dưới đây thấy mismatch → hủy session vừa tạo, không nối node.
|
||||
const token = ++fxRt.startToken;
|
||||
fxRt.starting = true;
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx || !ctx.audioWorklet || !masterBus || !masterBus.output || !masterBus.analyser) return;
|
||||
if (!ctx || !ctx.audioWorklet || !masterBus || !masterBus.output || !masterBus.analyser || !masterBus.masterFxGain || !masterBus.masterFxDryGain) 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 });
|
||||
@@ -1711,12 +1753,21 @@ async function fxRtStart() {
|
||||
} catch (e) {
|
||||
console.warn('[FX RT] start fail:', e);
|
||||
showToast('Không khởi động VST FX realtime: ' + (e.message || e), 'warning');
|
||||
fxRt.starting = false;
|
||||
return;
|
||||
}
|
||||
if (token !== fxRt.startToken) {
|
||||
// Bypass/stop giữa lúc engine đang load chain — hủy session vừa tạo.
|
||||
console.warn('[FX RT] start cancelled mid-flight — session stopped');
|
||||
try { window.SonicAPI.fxRealtimeStop({ session_id: st.session_id }).catch(() => {}); } catch (e2) {}
|
||||
fxRt.starting = false;
|
||||
return;
|
||||
}
|
||||
fxRt.sessionId = st.session_id;
|
||||
fxRtGuiPushShm();
|
||||
try {
|
||||
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608212401`);
|
||||
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608241200`);
|
||||
if (token !== fxRt.startToken) { fxRt.starting = false; return; }
|
||||
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://';
|
||||
@@ -1726,7 +1777,9 @@ async function fxRtStart() {
|
||||
fxRt.ws = ws;
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onopen = () => {
|
||||
if (token !== fxRt.startToken) { try { ws.close(); } catch (e) {} fxRt.starting = false; return; }
|
||||
fxRt.active = true;
|
||||
fxRt.starting = false;
|
||||
fxRt.inQueue = []; // bỏ audio stale sản xuất trước khi WS open (~4s load VST) — hết lag khởi đầu
|
||||
try { fxRtPushAllParams(); } catch (e) {}
|
||||
};
|
||||
@@ -1738,7 +1791,9 @@ async function fxRtStart() {
|
||||
const d = JSON.parse(ev.data);
|
||||
if (d && d.cmd === 'latency' && typeof d.total === 'number') {
|
||||
fxRt.latencyTotal = d.total;
|
||||
fxRtApplyPdc();
|
||||
// Chi delay dry path khi crossfade da xong (dry silent) — ap som
|
||||
// trong luc dry con la path nghe duoc se tao gap ~80ms.
|
||||
if (fxRt.fadedIn) fxRtApplyPdc();
|
||||
}
|
||||
} catch (e) {}
|
||||
return;
|
||||
@@ -1753,29 +1808,61 @@ async function fxRtStart() {
|
||||
};
|
||||
ws.onclose = () => fxRtStop(true);
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||||
// Worklet gom 4 block (1024 mẫu) vào 1 frame → tách 4 WS frame 512 floats
|
||||
// ở đây. Bridge VST3 nhận input dồn → take=4 → batch 1 call process 1024
|
||||
// mẫu (~3.5-4.75ms/block) → đủ 187.5/s (take=1: 5.5ms > budget 5.33ms →
|
||||
// drop → crackle). KHÔNG dùng f.subarray().buffer — nó gửi cả backing
|
||||
// buffer 8192B; slice() copy đúng 2048B.
|
||||
const fxRtSendFrames = (d) => {
|
||||
if (!fxRt.ws || fxRt.ws.readyState !== 1) return;
|
||||
try {
|
||||
const f = new Float32Array(d.data);
|
||||
if (f.length > 512) {
|
||||
for (let i = 0; i < f.length; i += 512) fxRt.ws.send(f.slice(i, i + 512).buffer);
|
||||
} else {
|
||||
fxRt.ws.send(d.data);
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
const fxRtInPump = () => {
|
||||
if (!fxRt.inQueue.length) return;
|
||||
const now = Date.now();
|
||||
while (fxRt.inQueue.length && now - fxRt.lastInSent >= FXRT_BURST_MS) {
|
||||
fxRt.lastInSent = now;
|
||||
fxRtSendFrames(fxRt.inQueue.shift());
|
||||
}
|
||||
if (fxRt.inQueue.length) {
|
||||
if (fxRt.pumpTimer) clearTimeout(fxRt.pumpTimer);
|
||||
fxRt.pumpTimer = setTimeout(fxRtInPump, Math.max(1, FXRT_BURST_MS - (now - fxRt.lastInSent)));
|
||||
} else if (fxRt.pumpTimer) {
|
||||
clearTimeout(fxRt.pumpTimer);
|
||||
fxRt.pumpTimer = null;
|
||||
}
|
||||
};
|
||||
fxRt.node.port.onmessage = (ev) => {
|
||||
const d = ev.data;
|
||||
if (!d || !fxRt.ws) return;
|
||||
if (d.type === 'started') {
|
||||
// Worklet bat dau phat (sau FILL) -> crossfade dry->fx muot. Token guard
|
||||
// chong race: neu stop/restart xay ra trong luc cho, bo qua.
|
||||
if (fxRt.fadedIn) return;
|
||||
setTimeout(() => {
|
||||
if (token !== fxRt.startToken) return;
|
||||
fxRt.fadedIn = true;
|
||||
fxRtCrossfade(true, token);
|
||||
// Dry gan silent (tc 0.025) roi moi tre pdcDelay — tranh gap khi delay
|
||||
// dang lon dan.
|
||||
setTimeout(() => fxRtApplyPdc(), 250);
|
||||
}, 30);
|
||||
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;
|
||||
// Worklet gom 4 block (1024 mẫu) vào 1 frame → tách 4 WS frame 512 floats
|
||||
// ở đây. Bridge VST3 nhận input dồn → take=4 → batch 1 call process 1024
|
||||
// mẫu (~3.5-4.75ms/block) → đủ 187.5/s (take=1: 5.5ms > budget 5.33ms →
|
||||
// drop → crackle). KHÔNG dùng f.subarray().buffer — nó gửi cả backing
|
||||
// buffer 8192B; slice() copy đúng 2048B.
|
||||
if (ws.readyState === 1) {
|
||||
try {
|
||||
const f = new Float32Array(d.data);
|
||||
if (f.length > 512) {
|
||||
for (let i = 0; i < f.length; i += 512) ws.send(f.slice(i, i + 512).buffer);
|
||||
} else {
|
||||
ws.send(d.data);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
fxRt.inQueue.push(d);
|
||||
fxRtInPump();
|
||||
};
|
||||
// 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
|
||||
@@ -1783,9 +1870,13 @@ async function fxRtStart() {
|
||||
// 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).
|
||||
// Crossfade activation: KHONG disconnect dry path — outputAnalyser van noi
|
||||
// masterFxDryGain -> pdcDelay -> output (gain 1); nhanh fx noi masterFxGain
|
||||
// (gain 0) -> output. Tin hieu 'started' tu worklet -> ramp 2 gain -> switch
|
||||
// muot, het click hard-rewire.
|
||||
try { masterBus.outputAnalyser.disconnect(masterBus.output); } catch (e) {}
|
||||
masterBus.outputAnalyser.connect(fxRt.node);
|
||||
fxRt.node.connect(masterBus.output);
|
||||
fxRt.node.connect(masterBus.masterFxGain);
|
||||
fxRt.chainKey = ck;
|
||||
} catch (e) {
|
||||
console.warn('[FX RT] setup fail:', e);
|
||||
@@ -1793,18 +1884,40 @@ async function fxRtStart() {
|
||||
}
|
||||
}
|
||||
function fxRtStop(silent) {
|
||||
// Phải bump token TRƯỚC guard early-return: lúc fxRtStart đang in-flight thì
|
||||
// active/ws/node/sessionId đều chưa có → guard sẽ return, nhưng start vẫn
|
||||
// phải bị hủy (token mismatch ở checkpoint).
|
||||
fxRt.startToken++;
|
||||
fxRt.starting = false;
|
||||
if (fxRt.pumpTimer) { clearInterval(fxRt.pumpTimer); fxRt.pumpTimer = null; }
|
||||
fxRt.inQueue = [];
|
||||
if (!fxRt.active && !fxRt.ws && !fxRt.node && !fxRt.sessionId) return;
|
||||
let _fxStopNode = null;
|
||||
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) {}
|
||||
_fxStopNode = fxRt.node;
|
||||
if (masterBus && masterBus.masterFxGain && masterBus.masterFxDryGain) {
|
||||
// Crossfade deactivation: ramp dry 0->1 / fx 1->0 roi MOI disconnect
|
||||
// (tranh click hard-switch nguoc). Dry path da noi san qua
|
||||
// masterFxDryGain — khong can reconnect outputAnalyser->output. PDC giam
|
||||
// ve 0 sau khi dry chiem cho (fx silent).
|
||||
try { fxRtCrossfade(false); } catch (e) {}
|
||||
setTimeout(() => {
|
||||
try { _fxStopNode.disconnect(); } catch (e) {}
|
||||
_fxStopNode.port.onmessage = null;
|
||||
if (fxRt.node === _fxStopNode) fxRt.node = null;
|
||||
fxRt.fadedIn = false;
|
||||
fxRtApplyPdc();
|
||||
}, 180);
|
||||
} else {
|
||||
// Fallback graph cu (khong co crossfade gains) — hard switch nhu cu.
|
||||
try { _fxStopNode.disconnect(); } catch (e) {}
|
||||
if (masterBus && masterBus.outputAnalyser && masterBus.output) {
|
||||
try { masterBus.outputAnalyser.connect(masterBus.output); } catch (e) {}
|
||||
}
|
||||
_fxStopNode.port.onmessage = null;
|
||||
if (fxRt.node === _fxStopNode) fxRt.node = null;
|
||||
}
|
||||
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) {}
|
||||
@@ -1814,7 +1927,9 @@ function fxRtStop(silent) {
|
||||
fxRt.active = false;
|
||||
fxRt.chainKey = null;
|
||||
fxRt.latencyTotal = 0;
|
||||
fxRtApplyPdc();
|
||||
// PDC giam ve 0 sau khi dry chiem cho (trong setTimeout cua nhanh crossfade)
|
||||
// hoac ngay neu khong co node — khong goi tai day de tranh gap 80ms.
|
||||
if (!_fxStopNode) fxRtApplyPdc();
|
||||
if (sid) {
|
||||
try { window.SonicAPI.fxRealtimeStop({ session_id: sid }).catch(() => {}); } catch (e) {}
|
||||
}
|
||||
@@ -1837,6 +1952,20 @@ function fxRtApplyPdc() {
|
||||
try { bus.pdcDelay.delayTime.setTargetAtTime(l / (ctx.sampleRate || 44100), ctx.currentTime, 0.05); } catch (e) {}
|
||||
}
|
||||
|
||||
// Crossfade master-FX switch: ramp masterFxGain/masterFxDryGain thay vi
|
||||
// hard-rewire (click). Hai gain nay duoc tao trong initMasterBus.
|
||||
function fxRtCrossfade(toFx, token) {
|
||||
const ctx = getAudioContext();
|
||||
const bus = masterBus;
|
||||
if (!ctx || !bus || !bus.masterFxGain || !bus.masterFxDryGain) return;
|
||||
if (token !== undefined && token !== fxRt.startToken) return;
|
||||
const tc = 0.025;
|
||||
try {
|
||||
bus.masterFxGain.gain.setTargetAtTime(toFx ? 1 : 0, ctx.currentTime, tc);
|
||||
bus.masterFxDryGain.gain.setTargetAtTime(toFx ? 0 : 1, ctx.currentTime, tc);
|
||||
} 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',
|
||||
@@ -2743,22 +2872,6 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, masterVolL, setMast
|
||||
React.createElement("div", {
|
||||
className: "flex flex-col items-stretch w-[300px] shrink-0 strip-bg rounded-lg p-2 text-slate-300 select-none shadow-2xl relative overflow-hidden"
|
||||
},
|
||||
React.createElement("div", { className: "space-y-1.5 mb-2" },
|
||||
React.createElement("button", {
|
||||
onClick: () => setShowMasteringModal(true),
|
||||
className: "w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-slate-300 tracking-tight"
|
||||
}, "FX CHAIN"),
|
||||
React.createElement("button", {
|
||||
onClick: () => setRefOpen(true),
|
||||
className: "w-full bg-slate-800 hover:bg-slate-700 text-[10px] font-semibold py-1 rounded border border-slate-700 text-cyan-300 tracking-tight"
|
||||
}, "A/B REF"),
|
||||
React.createElement("div", {
|
||||
className: "flex items-center justify-between bg-slate-950 border border-slate-800 rounded px-1.5 py-0.5 text-[10px] font-mono"
|
||||
},
|
||||
React.createElement("span", { className: "text-slate-400 truncate" }, "Output 1 / Output 2"),
|
||||
React.createElement("i", { className: "fa-solid fa-circle-notch text-[9px] text-slate-500" })
|
||||
)
|
||||
),
|
||||
React.createElement("div", { className: "flex flex-col items-center my-0.5" },
|
||||
React.createElement("span", { className: "text-[9px] text-slate-400 font-mono" }, panText || 'center'),
|
||||
React.createElement("div", { className: "flex items-center gap-1" },
|
||||
@@ -12443,7 +12556,7 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
|
||||
React.useEffect(() => {
|
||||
deadRef.current = false;
|
||||
setDead(false);
|
||||
const tick = setInterval(() => { const img = imgRef.current; if (img) img.src = url + '/frame?t=' + Date.now(); }, 100);
|
||||
const tick = setInterval(() => { const img = imgRef.current; if (img && img.complete) img.src = url + '/frame?t=' + Date.now(); }, 100);
|
||||
const ping = setInterval(() => {
|
||||
fetch(url + '/ping').then(r => { if (!r.ok) throw new Error('bad'); }).catch(() => {
|
||||
if (!deadRef.current) { deadRef.current = true; setDead(true); onDead && onDead(); }
|
||||
@@ -12501,7 +12614,7 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
|
||||
} catch (e) {}
|
||||
};
|
||||
refresh();
|
||||
const iv = setInterval(refresh, 500);
|
||||
const iv = setInterval(refresh, 250);
|
||||
return () => { alive = false; clearInterval(iv); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url]);
|
||||
@@ -12611,6 +12724,25 @@ let _persistFxGuiTrackId = null;
|
||||
// Latest preset_b64 captured from each plugin's embedded GUI (poll /preset)
|
||||
// - merged into the project on save so knob tweaks survive reload.
|
||||
let _capturedPresetByPath = {};
|
||||
// Baseline of each plugin's GUI state at embed-open time. /preset round-trips
|
||||
// through getState/applyPreset and the blob can differ from the value the
|
||||
// session started with; restarting the chain on that first difference reloads
|
||||
// the VST and crackles. First capture after open = baseline (no restart); only
|
||||
// a CHANGE vs baseline schedules the ~3s restart that syncs realtime to GUI.
|
||||
let _fxGuiBaselineByPath = {};
|
||||
// Debounced restart khi preset GUI đổi (preset poll) — _capturedPresetByPath
|
||||
// đổi làm chainKey đổi nhưng không ai gọi fxRtStart/reconcile → session
|
||||
// realtime GIỮ preset cũ (xoay knob trong GUI không đổi âm realtime). Restart
|
||||
// ~3s sau khi knob DỪNG xoay (không restart mỗi poll 1.5s giữa lúc kéo — load
|
||||
// VST ~4s ngắt âm). fn tự early-return khi chainKey không đổi.
|
||||
let _presetRestartTimers = {};
|
||||
function _schedulePresetRestart(key, fn, ms) {
|
||||
if (_presetRestartTimers[key]) clearTimeout(_presetRestartTimers[key]);
|
||||
_presetRestartTimers[key] = setTimeout(() => {
|
||||
delete _presetRestartTimers[key];
|
||||
try { fn(); } catch (e) {}
|
||||
}, ms);
|
||||
}
|
||||
const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
|
||||
const [activeType, setActiveType] = React.useState('eq');
|
||||
const [addModuleOpen, setAddModuleOpen] = React.useState(false);
|
||||
@@ -12661,16 +12793,25 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
|
||||
fxGuiOpenRef.current = fxGuiOpen;
|
||||
const closeFxGui = () => {
|
||||
const g = fxGuiOpenRef.current;
|
||||
if (g) { try { fetch(g.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} }
|
||||
if (g) {
|
||||
// Capture preset CUỐI trước khi đóng (poll 1.5s có thể chưa kịp khi chỉnh
|
||||
// xong đóng ngay) — gọi /preset TRƯỚC /close (close kill process bridge).
|
||||
try { fetch(g.url + '/preset').then(r => r.json()).then(d => { if (d && d.preset_b64 && g.path) _capturedPresetByPath[g.path] = d.preset_b64; }).catch(() => {}).then(() => { try { fetch(g.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} }); } catch (e) { try { fetch(g.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e2) {} }
|
||||
}
|
||||
setFxGuiOpen(null);
|
||||
if (g && g.path) {
|
||||
const _tk = 'track:' + ((track && track.id) || '') + ':' + g.path;
|
||||
if (_presetRestartTimers[_tk]) { clearTimeout(_presetRestartTimers[_tk]); delete _presetRestartTimers[_tk]; }
|
||||
delete _fxGuiBaselineByPath[g.path];
|
||||
}
|
||||
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) {} } }, []);
|
||||
React.useEffect(() => () => { const g = fxGuiOpenRef.current; if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} const _tk = 'track:' + ((track && track.id) || '') + ':' + g.path; if (_presetRestartTimers[_tk]) { clearTimeout(_presetRestartTimers[_tk]); delete _presetRestartTimers[_tk]; } if (g.path) delete _fxGuiBaselineByPath[g.path]; } }, []);
|
||||
// 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(() => {});
|
||||
fetch(fxGuiOpen.url + '/preset').then(r => r.json()).then(d => { if (d && d.preset_b64) { const _p = fxGuiOpen.path; const _b = _fxGuiBaselineByPath[_p]; if (_b === undefined) { _fxGuiBaselineByPath[_p] = d.preset_b64; _capturedPresetByPath[_p] = d.preset_b64; return; } if (d.preset_b64 !== _b) { _fxGuiBaselineByPath[_p] = d.preset_b64; _capturedPresetByPath[_p] = d.preset_b64; const _tid = track && track.id; _schedulePresetRestart('track:' + (_tid || '') + ':' + _p, () => { if (_tid && window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(_tid, {}); }, 3000); } } }).catch(() => {});
|
||||
}, 1500);
|
||||
return () => clearInterval(iv);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -12680,7 +12821,7 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
|
||||
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 => {
|
||||
window.SonicAPI.openFxGui({ path: s.path, name: s.name || s.path, embed: true, shm: fxRtTrackShmName(track && track.id), preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '' }).then(r => {
|
||||
if (r && r.embed_url) setFxGuiOpen({ path: s.path, url: r.embed_url });
|
||||
}).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -12688,7 +12829,7 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
|
||||
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 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), preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '' }).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) => {
|
||||
@@ -13244,17 +13385,25 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
masterGuiOpenRef.current = masterGuiOpen;
|
||||
const closeMasterGui = () => {
|
||||
const g = masterGuiOpenRef.current;
|
||||
if (g) { try { fetch(g.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} }
|
||||
if (g) {
|
||||
// Capture preset CUỐI trước khi đóng (giống closeFxGui) — /preset rồi /close.
|
||||
try { fetch(g.url + '/preset').then(r => r.json()).then(d => { if (d && d.preset_b64 && g.path) _capturedPresetByPath[g.path] = d.preset_b64; }).catch(() => {}).then(() => { try { fetch(g.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} }); } catch (e) { try { fetch(g.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e2) {} }
|
||||
}
|
||||
setMasterGuiOpen(null);
|
||||
if (g && g.path) {
|
||||
const _mk = 'master:' + g.path;
|
||||
if (_presetRestartTimers[_mk]) { clearTimeout(_presetRestartTimers[_mk]); delete _presetRestartTimers[_mk]; }
|
||||
delete _fxGuiBaselineByPath[g.path];
|
||||
}
|
||||
setActiveMasterVstPath(null);
|
||||
setOzState(prev => { const n = { ...(prev || {}) }; delete n.gui_open_path; delete n.gui_open_name; return n; });
|
||||
};
|
||||
React.useEffect(() => () => { const g = masterGuiOpenRef.current; if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} } }, []);
|
||||
React.useEffect(() => () => { const g = masterGuiOpenRef.current; if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} const _mk = 'master:' + g.path; if (_presetRestartTimers[_mk]) { clearTimeout(_presetRestartTimers[_mk]); delete _presetRestartTimers[_mk]; } if (g.path) delete _fxGuiBaselineByPath[g.path]; } }, []);
|
||||
// Capture GUI knob tweaks while the embedded GUI is open (bridge /preset).
|
||||
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(() => {});
|
||||
fetch(masterGuiOpen.url + '/preset').then(r => r.json()).then(d => { if (d && d.preset_b64) { const _p = masterGuiOpen.path; const _b = _fxGuiBaselineByPath[_p]; if (_b === undefined) { _fxGuiBaselineByPath[_p] = d.preset_b64; _capturedPresetByPath[_p] = d.preset_b64; return; } if (d.preset_b64 !== _b) { _fxGuiBaselineByPath[_p] = d.preset_b64; _capturedPresetByPath[_p] = d.preset_b64; _schedulePresetRestart('master:' + _p, () => fxRtStart(), 3000); } } }).catch(() => {});
|
||||
}, 1500);
|
||||
return () => clearInterval(iv);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -13264,7 +13413,7 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
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 => {
|
||||
window.SonicAPI.openFxGui({ path: s.path, name: ozState.gui_open_name || s.name || s.path, embed: true, shm: fxRt.sessionId || '', preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '' }).then(r => {
|
||||
if (r && r.embed_url) setMasterGuiOpen({ path: s.path, url: r.embed_url });
|
||||
}).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -13290,7 +13439,7 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
|
||||
const toggleMasterVst = (idx) => setMasterVstChain(masterVstChain.map((s, i) => i === idx ? { ...s, bypass: !s.bypass } : s));
|
||||
const removeMasterVst = (idx) => { const rem = masterVstChain[idx]; if (rem) delete _capturedPresetByPath[rem.path]; setMasterVstChain(masterVstChain.filter((_, i) => i !== idx)); };
|
||||
const moveMasterVst = (idx, dir) => { const j = idx + dir; if (j < 0 || j >= masterVstChain.length) return; const next = [...masterVstChain]; const mv = next.splice(idx, 1)[0]; next.splice(j, 0, mv); setMasterVstChain(next); };
|
||||
const openMasterVstGui = (s) => { if (!s || !s.path) { window.showToast && window.showToast('Chọn VST FX trước', 'warning'); return; } if (masterGuiOpen) closeMasterGui(); if (s.path) setActiveMasterVstPath(s.path); 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: fxRt.sessionId || '' }).then(r => { if (r && r.embed_url) { setMasterGuiOpen({ path: s.path, url: r.embed_url, name: s.name || s.path }); setOzState(prev => ({ ...(prev || {}), gui_open_path: s.path, gui_open_name: s.name || s.path })); } else if (r && r.already_running && r.embed_url) { setMasterGuiOpen({ path: s.path, url: r.embed_url, name: s.name || s.path }); setOzState(prev => ({ ...(prev || {}), gui_open_path: s.path, gui_open_name: 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 openMasterVstGui = (s) => { if (!s || !s.path) { window.showToast && window.showToast('Chọn VST FX trước', 'warning'); return; } if (masterGuiOpen) closeMasterGui(); if (s.path) setActiveMasterVstPath(s.path); 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: fxRt.sessionId || '', preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '' }).then(r => { if (r && r.embed_url) { setMasterGuiOpen({ path: s.path, url: r.embed_url, name: s.name || s.path }); setOzState(prev => ({ ...(prev || {}), gui_open_path: s.path, gui_open_name: s.name || s.path })); } else if (r && r.already_running && r.embed_url) { setMasterGuiOpen({ path: s.path, url: r.embed_url, name: s.name || s.path }); setOzState(prev => ({ ...(prev || {}), gui_open_path: s.path, gui_open_name: 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 addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.path === path); if (!fx) return; setMasterVstChain([...masterVstChain, { type: 'vst3', path: fx.path, name: fx.name || fx.id, preset_b64: '', bypass: false }]);
|
||||
openMasterVstGui({ path: fx.path, name: fx.name || fx.id }); };
|
||||
const changeMasterVstPlugin = (idx, path) => { const fx = (masterFxList || []).find(f => f.path === path); delete _capturedPresetByPath[path]; setMasterVstChain(masterVstChain.map((s, i) => i === idx ? { ...s, path, name: fx ? (fx.name || fx.id) : s.name, preset_b64: '' } : s)); };
|
||||
@@ -19052,7 +19201,7 @@ const App = () => {
|
||||
// masterConnected nhưng fxRt không tự dừng → tắt PWR mà âm không đổi.
|
||||
try {
|
||||
if (fxRtChain().length) { fxRtStart(); }
|
||||
else if (fxRt.active) { fxRtStop(); }
|
||||
else if (fxRt.active || fxRt.starting) { fxRtStop(); }
|
||||
} catch (e) { console.warn('[FX RT] resync fail:', e); }
|
||||
// Re-sync live track routes: mastering ON → mọi track qua chain (♪ bị
|
||||
// override bởi effMidiBypass/effAudioBypass) — nút PWR bật/tắt phải áp
|
||||
@@ -23684,11 +23833,11 @@ 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: fxRtTrackChainKey(chain), paramsBySlot: chain.map(s => fxRtEntryParams(s, null)), 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, lastInSent: 0 };
|
||||
fxRtTracks[key] = entry;
|
||||
fxRtGuiPushShm();
|
||||
try {
|
||||
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608212401`);
|
||||
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608241200`);
|
||||
const node = new AudioWorkletNode(ctx, 'sf-fx-realtime', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2] });
|
||||
entry.node = node;
|
||||
const u = new URL(API_BASE_URL);
|
||||
@@ -23725,16 +23874,34 @@ const App = () => {
|
||||
}
|
||||
if (d.type !== 'in') return;
|
||||
// Burst 4 block (1024 mẫu) → tách 4 WS frame 512 floats → bridge batch 4.
|
||||
if (ws.readyState === 1) {
|
||||
try {
|
||||
const f = new Float32Array(d.data);
|
||||
if (f.length > 512) {
|
||||
for (let i = 0; i < f.length; i += 512) ws.send(f.slice(i, i + 512).buffer);
|
||||
} else {
|
||||
ws.send(d.data);
|
||||
// Pace 1 burst/21ms (giống master) — tránh dồn frame sau stall làm tràn ring.
|
||||
entry.inQueue.push(d);
|
||||
const pump = () => {
|
||||
if (!entry.inQueue.length) return;
|
||||
const now = Date.now();
|
||||
while (entry.inQueue.length && now - entry.lastInSent >= FXRT_BURST_MS) {
|
||||
entry.lastInSent = now;
|
||||
const dd = entry.inQueue.shift();
|
||||
if (entry.ws && entry.ws.readyState === 1) {
|
||||
try {
|
||||
const f = new Float32Array(dd.data);
|
||||
if (f.length > 512) {
|
||||
for (let i = 0; i < f.length; i += 512) entry.ws.send(f.slice(i, i + 512).buffer);
|
||||
} else {
|
||||
entry.ws.send(dd.data);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
if (entry.inQueue.length) {
|
||||
if (entry.pumpTimer) clearTimeout(entry.pumpTimer);
|
||||
entry.pumpTimer = setTimeout(pump, Math.max(1, FXRT_BURST_MS - (now - entry.lastInSent)));
|
||||
} else if (entry.pumpTimer) {
|
||||
clearTimeout(entry.pumpTimer);
|
||||
entry.pumpTimer = null;
|
||||
}
|
||||
};
|
||||
pump();
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[FX RT track] setup fail', trackId, path, e);
|
||||
@@ -23750,6 +23917,23 @@ const App = () => {
|
||||
entry.spliced = null;
|
||||
} catch (e) { console.warn('[FX RT track] unsplice fail', trackId, path, e); }
|
||||
};
|
||||
// Chain đổi trên session ĐANG CHẠY — set_chain KHÔNG restart (bridge swap
|
||||
// atomic khi worker load xong) → load VST FX / đổi preset GUI không ngắt
|
||||
// âm/crackle. entry.chainKey cập nhật ngay — reconcile sau đó thấy khớp.
|
||||
const fxRtTrackSetChain = (trackId, path, track, chainKey) => {
|
||||
const key = trackId + ':' + path;
|
||||
const entry = fxRtTracks[key];
|
||||
if (!entry || !entry.ws || entry.ws.readyState !== 1) return;
|
||||
const chain = fxRtTrackChain(track);
|
||||
if (!chain.length) 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 });
|
||||
entry.chainKey = chainKey;
|
||||
window.SonicAPI.fxRealtimeSetChain({ session_id: entry.sessionId, fx_chain: chainPayload }).then(() => {
|
||||
try { (entry.paramsBySlot || []).forEach((pl, slot) => pl.forEach(p => { if (entry.ws && entry.ws.readyState === 1) entry.ws.send(JSON.stringify({ cmd: 'set_param', slot, key: p.key, value: p.value })); })); } catch (e) {}
|
||||
}).catch(e => console.warn('[FX RT track] set-chain fail', trackId, path, e));
|
||||
};
|
||||
const fxRtTrackSplice = (trackId, path) => {
|
||||
const key = trackId + ':' + path;
|
||||
const entry = fxRtTracks[key];
|
||||
@@ -23800,7 +23984,14 @@ 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.chainKey !== chainKey) {
|
||||
// Chain đổi (load/đổi VST FX, preset GUI, bypass slot) — session
|
||||
// đang chạy → set_chain KHÔNG restart (bridge swap atomic khi load
|
||||
// xong) → hết crackle khi load VST / chỉnh thông số. Session chưa
|
||||
// ready (start in-flight) → stop+start lại cho đúng chain.
|
||||
if (entry.ready) fxRtTrackSetChain(trackId, track, p, chainKey);
|
||||
else { 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);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -87,6 +87,7 @@ window.API_BASE_URL = (window.API_BASE_URL || window.location.origin).replace(/\
|
||||
// /ws/fx-realtime/{id} đẩy/kéo block audio; stop → tắt bridge.
|
||||
fxRealtimeStart: (payload) => apiRequest('/api/v1/plugins/fx-realtime/start', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
fxRealtimeStop: (payload) => apiRequest('/api/v1/plugins/fx-realtime/stop', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
fxRealtimeSetChain: (payload) => apiRequest('/api/v1/plugins/fx-realtime/set-chain', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
// Mở native GUI của VST FX (mastering chain / FX rack) qua bridge
|
||||
openFxGui: (payload) => apiRequest('/api/v1/plugins/fx-gui', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
soundfontRender: (payload) => apiRequest('/api/v1/plugins/soundfont-render', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
|
||||
@@ -24,7 +24,8 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor {
|
||||
super();
|
||||
this.BLOCK = 256; // FXRT_BLOCK
|
||||
this.BURST = 4; // số block gom 1 frame (bridge batch)
|
||||
this.FILL = 8; // block buffer tối thiểu trước khi phát
|
||||
this.FILL = 16; // 16 block ~86ms: buffer khởi đầu — hấp thụ jitter main-thread (GUI decode, rAF, solo restart)
|
||||
this.REBUFFER = 2; // 2 block ~11ms: sau underrun chỉ cần bấy nhiêu để phát lại — gap ngắn, không còn 86ms im lặng
|
||||
this.CAP = 32; // cap queue chặn overflow
|
||||
this.bufLen = this.BLOCK * this.BURST;
|
||||
this.inL = new Float32Array(this.bufLen);
|
||||
@@ -33,6 +34,10 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor {
|
||||
this.outQueue = []; // [{l: Float32Array, r: Float32Array}]
|
||||
this.outPos = 0;
|
||||
this.playing = false; // false → im lặng (đang chờ đủ FILL)
|
||||
this.hasStarted = false; // đã phát block đầu chưa — phân biệt FILL khởi đầu vs REBUFFER sau underrun
|
||||
this.FADE = 512; // fade-in 512 mẫu (~11ms @48k) khi bắt đầu phát/resume — hết click
|
||||
this.outGain = 0; // gain phát hiện tại (0→1 trong fade)
|
||||
this.fadeLeft = 0; // số mẫu còn lại của fade
|
||||
this.port.onmessage = (e) => {
|
||||
const d = e.data;
|
||||
if (d && d.type === 'set_param') { this.port.postMessage(d); return; }
|
||||
@@ -76,16 +81,30 @@ class SfFxRealtimeProcessor extends AudioWorkletProcessor {
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (this.playing && this.outQueue.length === 0) this.playing = false;
|
||||
if (!this.playing) {
|
||||
if (this.outQueue.length >= this.FILL) this.playing = true;
|
||||
else {
|
||||
// Khởi đầu: chờ đủ FILL (hấp thụ jitter). Sau underrun: chỉ cần
|
||||
// REBUFFER (2 block ~11ms) — gap ngắn thay vì 86ms im lặng.
|
||||
const need = this.hasStarted ? this.REBUFFER : this.FILL;
|
||||
if (this.outQueue.length >= need) {
|
||||
this.playing = true;
|
||||
this.hasStarted = true;
|
||||
// Fade-in mượt (hết click lúc FILL xong / resume sau underrun); báo
|
||||
// main thread để crossfade dry→fx đúng thời điểm.
|
||||
this.outGain = 0;
|
||||
this.fadeLeft = this.FADE;
|
||||
this.port.postMessage({ type: 'started' });
|
||||
} else {
|
||||
outL[i] = 0;
|
||||
if (outR) outR[i] = 0;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const blk = this.outQueue[0];
|
||||
outL[i] = blk.l[this.outPos];
|
||||
if (outR) outR[i] = blk.r[this.outPos];
|
||||
if (this.fadeLeft > 0) {
|
||||
this.outGain = Math.min(1, this.outGain + 1 / this.FADE);
|
||||
this.fadeLeft--;
|
||||
}
|
||||
outL[i] = blk.l[this.outPos] * this.outGain;
|
||||
if (outR) outR[i] = blk.r[this.outPos] * this.outGain;
|
||||
this.outPos++;
|
||||
if (this.outPos >= this.BLOCK) {
|
||||
this.outQueue.shift();
|
||||
|
||||
Reference in New Issue
Block a user