fix(fx): realtime VST ngắt quãng khi mở MASTERING PANEL + PWR master không ảnh hưởng âm; pacing WS input/output, gate fxRt theo masterConnected, worklet cap 32

This commit is contained in:
2026-08-21 21:48:56 +07:00
parent 4e3f2fd08a
commit bb6658e03d
17 changed files with 1813 additions and 142 deletions
+536 -61
View File
@@ -1541,9 +1541,10 @@ function createTrackFxModule(type, ctx, params) {
nodes = { gLL, gRL, gLR, gRR };
} else if (type === 'eqpro') {
return createEqProModule(ctx, params);
} else if (type === 'carla') {
// Carla Bridge = VST FX chạy NGOÀI (ứng dụng ngoài, user tự cài) — trong
// WebAudio graph chỉ là pass-through (không thêm DSP): âm track đi thẳng.
} else if (type === 'carla' || type === 'vst3') {
// Carla Bridge / VST FX = xử lý NGOÀI (Carla standalone / fx_vst_bridge
// --render-fx khi export) — trong WebAudio graph chỉ là pass-through
// (không thêm DSP): âm track đi thẳng, đúng vị trí chain.
input.connect(output);
nodes = { passthrough: input };
} else {
@@ -1567,8 +1568,12 @@ function createTrackFxModule(type, ctx, params) {
// keyed in a LOCAL map so the live graph is never touched. Audio clips are then
// scheduled through node.gainNode, and the offline master bus applies the
// mastering chain — the exported file therefore matches what you hear.
function buildOfflineTrackNode(track, ctx, nodeMap) {
function buildOfflineTrackNode(track, ctx, nodeMap, opts) {
if (!track || nodeMap[track.id]) return nodeMap[track.id] || null;
// directOut: render track RIÊNG (track FX chain, chưa qua mastering bus) —
// dùng cho export có VST FX: mỗi track render solo → áp VST chain qua
// fx_vst_bridge → cộng mix → mastering 1 lần trên tổng.
const directOut = !!(opts && opts.directOut);
const gainNode = ctx.createGain();
const volDb = track.volumeDb ?? 0;
gainNode.gain.setValueAtTime(volDb <= -50 ? 0 : Math.pow(10, volDb / 20), 0);
@@ -1577,10 +1582,16 @@ function buildOfflineTrackNode(track, ctx, nodeMap) {
const analyserNode = ctx.createAnalyser();
analyserNode.fftSize = 2048;
pannerNode.connect(analyserNode);
if (!masterBus) initMasterBus(ctx);
const route = createMasteringRoute(ctx, track, masterBus);
analyserNode.connect(route.routeGain);
analyserNode.connect(route.dryGain);
const node = { gainNode, pannerNode, analyserNode, route: null, fxStopFn: null, sfEntry: null, sfOut: null, sfRouteGain: null, sfDryGain: null };
if (directOut) {
analyserNode.connect(ctx.destination);
} else {
if (!masterBus) initMasterBus(ctx);
const route = createMasteringRoute(ctx, track, masterBus);
analyserNode.connect(route.routeGain);
analyserNode.connect(route.dryGain);
node.route = route;
}
let fxStopFn;
const fxEntry = ctx.createGain();
const fxLegacyIn = ctx.createGain();
@@ -1605,7 +1616,7 @@ function buildOfflineTrackNode(track, ctx, nodeMap) {
} else {
fxLegacyIn.connect(pannerNode);
}
const node = { gainNode, pannerNode, analyserNode, route, fxStopFn, sfEntry: null, sfOut: null, sfRouteGain: null, sfDryGain: null };
node.fxStopFn = fxStopFn || null;
// Soundfont (MIDI cache) chain — mirrors the live node so cached MIDI buffers
// flow through the track FX modules + mastering exactly like playback.
if ((track.midiItems && track.midiItems.length > 0)) {
@@ -1622,8 +1633,12 @@ function buildOfflineTrackNode(track, ctx, nodeMap) {
sfDryGain.gain.value = sfBypass ? 1 : 0;
sfPan.connect(sfRouteGain);
sfPan.connect(sfDryGain);
sfRouteGain.connect(masterBus.input);
sfDryGain.connect(masterBus.dryInput);
if (directOut) {
sfPan.connect(ctx.destination);
} else {
sfRouteGain.connect(masterBus.input);
sfDryGain.connect(masterBus.dryInput);
}
const sfMods = fxEnabled ? fxChain.filter(m => m && m.active !== false).map(m => {
try { return createTrackFxModule(m.type || m, ctx, m.params); } catch (e) { return null; }
}).filter(Boolean) : [];
@@ -1639,6 +1654,187 @@ function buildOfflineTrackNode(track, ctx, nodeMap) {
return node;
}
// ── VST FX export helpers (fx_vst_bridge --render-fx qua engine /fx-render) ──
// Track + master VST chains là pass-through trong WebAudio (live + offline
// graph); xử lý THẬT chỉ chạy được ở export OFFLINE qua fx_vst_bridge (one-shot
// render, không SHM realtime — xem native_bridge/src/main_fx.cpp).
function audioBufferToInterleaved(buf) {
const ch = buf.numberOfChannels;
const len = buf.length;
const mono = ch < 2;
const out = new Float32Array(len * (mono ? 1 : 2));
const L = buf.getChannelData(0);
if (mono) {
out.set(L);
} else {
const R = buf.getChannelData(1);
for (let i = 0; i < len; i++) { out[i * 2] = L[i]; out[i * 2 + 1] = R[i]; }
}
return { pcm: out, channels: mono ? 1 : 2, sampleRate: buf.sampleRate };
}
function interleavedToAudioBuffer(pcm, sampleRate, channels, ctx) {
const n = Math.floor(pcm.length / channels);
const buf = ctx.createBuffer(channels, n, sampleRate);
for (let ch = 0; ch < channels; ch++) {
const d = buf.getChannelData(ch);
for (let i = 0; i < n; i++) d[i] = pcm[i * channels + ch];
}
return buf;
}
function mixInterleaved(a, b) {
const n = Math.max(a.length, b.length);
const out = new Float32Array(n);
for (let i = 0; i < n; i++) {
out[i] = (i < a.length ? a[i] : 0) + (i < b.length ? b[i] : 0);
}
return out;
}
function activeVstChain(chain) {
return (chain || []).filter(s => s && s.path && !s.bypass);
}
function encodeFloatWavBlob(pcm, sampleRate, channels) {
// IEEE float32 WAV — bit-exact input cho fx_vst_bridge (không dither/quantize
// ở đây; final export encode giữ nguyên chính sách hiện có).
const len = Math.floor(pcm.length / channels);
const bps = 4;
const dataBytes = len * bps * channels;
const buf = new ArrayBuffer(44 + dataBytes);
const v = new DataView(buf);
const ws = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
ws(0, 'RIFF'); v.setUint32(4, 36 + dataBytes, true); ws(8, 'WAVE');
ws(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 3, true);
v.setUint16(22, channels, true); v.setUint32(24, sampleRate, true);
v.setUint32(28, sampleRate * bps * channels, true);
v.setUint16(32, bps, true); v.setUint16(34, 32, true);
ws(36, 'data'); v.setUint32(40, dataBytes, true);
let o = 44;
for (let i = 0; i < pcm.length; i++) v.setFloat32(o + i * 4, pcm[i], true);
return new Blob([buf], { type: 'audio/wav' });
}
// ── Realtime VST FX (SHM bridge) — master chain LIVE qua fx_vst_bridge ─────
// --realtime-fx: engine giữ SHM ring FxRealtimeIPC, spawn bridge; browser gửi
// block 256 stereo float32 qua WS → engine SHM → bridge xử lý → SHM → engine →
// WS → AudioWorklet (sf-fx-realtime.js) phát. Master: masterBus.output →
// worklet → analyser (sau master fader, trước metering/destination — khớp thứ
// tự offline export). Track: mỗi track 1 session/worklet splice vào CẢ 2 đường
// chain (audio-clips + MIDI/SF) — mọi luồng track qua bridge trước main out.
const fxRt = { active: false, sessionId: null, ws: null, node: null, inQueue: [], pumpTimer: null };
function fxRtChain() {
const ms = window.currentMasteringSettings;
if (!ms || !ms.masterConnected || ms.isBypassed) return [];
const chain = activeVstChain((ms && ms.vstFxChain) || []);
return (chain.length > 0 && !(ms && ms.vstBypass)) ? chain : [];
}
async function fxRtStart() {
if (fxRt.active) {
// masterBus bị rebuild (recovery/export) → node thuộc graph cũ → restart.
if (!fxRt.node || fxRt.node.context !== getAudioContext()) fxRtStop(true);
else return;
}
const chain = fxRtChain();
if (!chain.length) return;
const ctx = getAudioContext();
if (!ctx || !ctx.audioWorklet || !masterBus || !masterBus.output || !masterBus.analyser) return;
const chainPayload = chain.map(s => ({ type: 'vst3', path: s.path, name: s.name || s.path, preset_b64: s.preset_b64 || '', bypass: false }));
let st;
try {
st = await window.SonicAPI.fxRealtimeStart({ fx_chain: chainPayload, sample_rate: ctx.sampleRate || 44100 });
} catch (e) {
console.warn('[FX RT] start fail:', e);
showToast('Không khởi động VST FX realtime: ' + (e.message || e), 'warning');
return;
}
fxRt.sessionId = st.session_id;
try {
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608212200`);
fxRt.node = new AudioWorkletNode(ctx, 'sf-fx-realtime', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2] });
const u = new URL(API_BASE_URL);
const wsProto = u.protocol === 'https:' ? 'wss://' : 'ws://';
const tok = localStorage.getItem('sonic_token') || '';
const wsUrl = `${wsProto}${u.host}${st.ws_url}${tok ? '?token=' + encodeURIComponent(tok) : ''}`;
const ws = new WebSocket(wsUrl);
fxRt.ws = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => { fxRt.active = true; };
ws.onmessage = (ev) => {
if (!(ev.data instanceof ArrayBuffer) || !fxRt.node) return;
const f = new Float32Array(ev.data);
const half = f.length / 2;
const l = new Float32Array(half);
const r = new Float32Array(half);
for (let i = 0; i < half; i++) { l[i] = f[i * 2]; r[i] = f[i * 2 + 1]; }
fxRt.node.port.postMessage({ type: 'out', l, r });
};
ws.onclose = () => fxRtStop(true);
ws.onerror = () => { try { ws.close(); } catch (e) {} };
fxRt.node.port.onmessage = (ev) => {
const d = ev.data;
if (!d || d.type !== 'in' || !fxRt.ws) return;
if (fxRt.inQueue.length > 64) fxRt.inQueue.shift();
fxRt.inQueue.push(d.data);
};
// Pace input: tối đa 1 block/kỳ (~5.8ms). Gửi dồn theo burst (main thread bị
// rAF renderFrame block khi mở MASTERING PANEL) → SHM ring 4-slot tràn → mất
// block → ngắt quãng.
const blockMs = Math.max(4, Math.round((256 / (ctx.sampleRate || 44100)) * 1000));
if (fxRt.pumpTimer) clearInterval(fxRt.pumpTimer);
fxRt.pumpTimer = setInterval(() => {
const ws = fxRt.ws;
if (!ws || ws.readyState !== 1 || !fxRt.inQueue.length) return;
const d = fxRt.inQueue.shift();
try { ws.send(d); } catch (e) { fxRt.inQueue.unshift(d); }
}, blockMs);
try { masterBus.output.disconnect(masterBus.analyser); } catch (e) {}
masterBus.output.connect(fxRt.node);
fxRt.node.connect(masterBus.analyser);
} catch (e) {
console.warn('[FX RT] setup fail:', e);
fxRtStop(true);
}
}
function fxRtStop(silent) {
if (fxRt.pumpTimer) { clearInterval(fxRt.pumpTimer); fxRt.pumpTimer = null; }
fxRt.inQueue = [];
if (!fxRt.active && !fxRt.ws && !fxRt.node && !fxRt.sessionId) return;
try {
if (fxRt.node) {
fxRt.node.disconnect();
if (masterBus && masterBus.output && masterBus.analyser) {
try { masterBus.output.connect(masterBus.analyser); } catch (e) {}
}
fxRt.node.port.onmessage = null;
fxRt.node = null;
}
} catch (e) {}
try { if (fxRt.ws) { fxRt.ws.onclose = null; fxRt.ws.onmessage = null; try { fxRt.ws.close(); } catch (e) {} fxRt.ws = null; } } catch (e) {}
const sid = fxRt.sessionId;
fxRt.sessionId = null;
fxRt.active = false;
if (sid) {
try { window.SonicAPI.fxRealtimeStop({ session_id: sid }).catch(() => {}); } catch (e) {}
}
}
// ── Realtime VST FX per-track (SHM bridge) — mọi luồng track qua bridge ─────
// Mỗi track: session riêng (1 WS/SHM) cho TỪNG đường chain có âm — audio-clips
// (path 'audio', splice fxEntry→mods→worklet→fxLegacyIn) và MIDI/SF (path 'sf',
// splice sfEntry→mods→worklet→sfOut). Track vừa clips vừa MIDI → 2 session
// cùng chain (khớp app: builtin FX cũng chạy instance riêng mỗi path; offline
// export áp VST lên track mix tổng). Thứ tự: builtin FX → VST (khớp offline).
const fxRtTracks = {}; // 'trackId:audio|sf' → { sessionId, ws, node, ready, path, spliced, chainKey }
function fxRtTrackChain(track) {
if (!track) return [];
const chain = activeVstChain(track.vstFxChain || []);
return (chain.length > 0 && !track.vstFxBypass) ? chain : [];
}
function fxRtTrackPaths(track) {
const paths = [];
if (track && ((track.clips && track.clips.length > 0) || !!track.buffer || (track.sections && track.sections.length > 0))) paths.push('audio');
if (track && ((track.midiItems && track.midiItems.length > 0) || track.type === 'MIDI' || !!track.instrumentId)) paths.push('sf');
return paths;
}
// Track-EQ preset library (unified_fx_rack_panel.md §III.1) — band gains only.
const TRACK_EQ_PRESETS = {
flat: { name: 'Flat / Reset', g: [0, 0, 0, 0] },
@@ -2268,7 +2464,6 @@ const MixerStrip = ({ track, index, onUpdateTrack, trackVuRefs }) => {
// ── Master Strip Console Component (from md/47_MASTER_STRIP_CONSOLE.md) ──
const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal, setShowMasteringModal, masteringSettings, setMasteringSettings, isPlaying }) => {
const [isFxActive, setIsFxActive] = React.useState(true);
const [isTestPlaying, setIsTestPlaying] = React.useState(false);
const [isMuted, setIsMuted] = React.useState(false);
const [isMono, setIsMono] = React.useState(false);
@@ -2535,7 +2730,7 @@ const MasterStripConsole = ({ masterVolume, setMasterVolume, showMasteringModal,
}
},
title: "Bật/Tắt MASTER FX CHAIN Bypass"
}, [React.createElement("i", { className: "fa-solid fa-power-off" + (isFxActive ? " text-emerald-400" : " text-slate-500"), key: "ico" }), React.createElement("span", { key: "lbl", className: "text-[7px] font-bold" + (isFxActive ? " text-emerald-300" : " text-slate-400") }, "PWR")]),
}, [React.createElement("i", { className: "fa-solid fa-power-off" + (isMasterActive ? " text-emerald-400" : " text-slate-500"), key: "ico" }), React.createElement("span", { key: "lbl", className: "text-[7px] font-bold" + (isMasterActive ? " text-emerald-300" : " text-slate-400") }, "PWR")]),
React.createElement("button", { className: "btn-daw h-[18px] rounded text-slate-400 text-[8px]", title: "Trim Envelope" }, "TRIM"),
React.createElement("button", { className: "btn-daw h-[18px] rounded text-slate-400 text-[9px]", title: "Session Info" },
React.createElement("i", { className: "fa-solid fa-info" })
@@ -2742,7 +2937,7 @@ const TrackStripConsole = ({ track, index, onUpdateTrack, trackVuRefs, style })
if (onUpdateTrack) onUpdateTrack(track.id, { fxActive: next });
// Live re-route: rebuild both FX chains (audio + SF) so EVERY item
// (midi/section/audioclip) immediately follows the FX power state.
if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id);
if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id, { fxActive: next });
},
className: "btn-daw h-[24px] rounded flex items-center justify-center text-[8px]" + (track.fxActive !== false ? " text-emerald-400" : " text-slate-500"),
title: "Toggle FX Power: ON (sáng) = MỌI items (MIDI/section/audioclip) qua FX Rack Panel (nút A/♪ chỉ ảnh hưởng Mastering FX Chain); OFF (tối) = bỏ toàn bộ track FX"
@@ -11296,7 +11491,7 @@ const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsLi
fx_chain: masteringSettings.vstFxChain.map(s => ({ type: s.type || 'vst3', path: s.path || '', name: s.name || '', preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '', bypass: !!s.bypass })),
volume_db: (typeof masterVolume === 'number' && isFinite(masterVolume)) ? masterVolume : 0,
mute: false,
bypass: !!masteringSettings.vstBypass
bypass: !masteringSettings.masterConnected || !!masteringSettings.vstBypass
}
} : {})
},
@@ -12192,7 +12387,7 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
...chain.map((m, i) => ({ kind: 'builtin', m, i })),
...vstChain.map((s, i) => ({ kind: 'vst', s, i })),
];
const setVstChain = (next) => { if (onUpdateTrack) onUpdateTrack(track.id, { vstFxChain: next, vstFxBypass: vstBypass }); };
const setVstChain = (next) => { if (onUpdateTrack) onUpdateTrack(track.id, { vstFxChain: next, vstFxBypass: vstBypass }); if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id, { vstFxChain: next, vstFxBypass: vstBypass }); };
// Embedded VST GUI state (PLAN_MASTERBUS_VST_GUI_EMBED.md Phase 3)
const [fxGuiOpen, setFxGuiOpenState] = React.useState(_persistFxGuiOpen); // { path, url } | null
const setFxGuiOpen = (v) => { _persistFxGuiOpen = v; _persistFxGuiTrackId = v ? (track && track.id) : null; setFxGuiOpenState(v); };
@@ -12262,7 +12457,7 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
const setChain = (next) => {
if (onUpdateTrack) onUpdateTrack(track.id, { fxChain: next });
if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id);
if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id, { fxChain: next });
};
const paramsOf = (m) => ({ ...(TRACK_FX_DEFAULTS[m.type] || {}), ...(m.params || {}) });
const setParams = (idx, patch) => {
@@ -12564,13 +12759,13 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
{/* VST FX toolbar — VST3 (Ozone...) áp dụng lúc Export/Offline render (preview = âm export) */}
<div className="flex items-center justify-between flex-wrap gap-2 shrink-0">
<span className="text-[9px] text-slate-500 font-mono truncate">VST3 (Ozone...) — realtime browser vẫn WebAudio builtin; VST áp dụng lúc Export/Offline render</span>
<span className="text-[9px] text-slate-500 font-mono truncate">VST3 — realtime qua bridge (SHM) khi play + áp dụng lúc Export/Offline render</span>
<div className="flex items-center gap-2 shrink-0">
<button onClick={previewFx} className="px-2 py-1 bg-teal-800 hover:bg-teal-700 text-teal-100 text-[10px] font-semibold rounded transition flex items-center gap-1">
<i data-lucide="play" className="w-3 h-3"></i> Nghe thử (render offline)
</button>
<label className="flex items-center gap-1 text-[10px] text-slate-400 font-mono cursor-pointer">
<input type="checkbox" checked={vstBypass} onChange={e => { const val = e.target.checked; setVstBypass(val); if (onUpdateTrack) onUpdateTrack(track.id, { vstFxBypass: val }); }} className="w-3 h-3" /> Bypass
<input type="checkbox" checked={vstBypass} onChange={e => { const val = e.target.checked; setVstBypass(val); if (onUpdateTrack) onUpdateTrack(track.id, { vstFxBypass: val }); if (window.__rebuildTrackFxGraph) window.__rebuildTrackFxGraph(track.id, { vstFxBypass: val }); }} className="w-3 h-3" /> Bypass
</label>
</div>
</div>
@@ -18552,6 +18747,12 @@ const App = () => {
if (audioCtx && masterBus) {
toggleMasteringOnMaster(masteringSettings.masterConnected, masteringSettings.isBypassed);
applyMasteringSettings(masteringSettings);
// Đồng bộ realtime VST master với PWR/Bypass: WebAudio graph đã đổi theo
// masterConnected nhưng fxRt không tự dừng → tắt PWR mà âm không đổi.
try {
if (fxRtChain().length) { if (!fxRt.active) fxRtStart(); }
else if (fxRt.active) { fxRtStop(); }
} catch (e) { console.warn('[FX RT] resync fail:', e); }
// Re-sync live track routes: mastering ON → mọi track qua chain (♪ bị
// override bởi effMidiBypass/effAudioBypass) — nút PWR bật/tắt phải áp
// ngay lên node đang phát (không chờ tracks effect).
@@ -23094,13 +23295,14 @@ const App = () => {
// re-routes ONLY this track's fx chain (fxEntry → active modules → fxLegacyIn),
// leaving other tracks and the master bus untouched. Called by the FX Rack
// panel on add/remove/reorder/toggle/param change — live while playing.
const rebuildTrackFxGraph = (trackId) => {
const rebuildTrackFxGraph = (trackId, patch) => {
const node = activeTrackNodesRef.current[trackId];
if (!node || !node.fxEntry || !node.fxLegacyIn) return;
try {
const list = (activeTracksRef.current && activeTracksRef.current.length) ? activeTracksRef.current : tracks;
const track = list.find(t => t.id === trackId);
let track = list.find(t => t.id === trackId);
if (!track) return;
if (patch) track = { ...track, ...patch };
node.fxEntry.disconnect();
const fxEnabled = track.fxActive !== false;
const chainMods = fxEnabled ? (track.fxChain || []).filter(m => m && m.active !== false).map(m => {
@@ -23130,6 +23332,10 @@ const App = () => {
if (node.sfPan && getAudioContext()) {
node.sfPan.pan.setTargetAtTime((track.pan ?? 0) / 100, getAudioContext().currentTime, 0.02);
}
// Realtime VST FX per-track: chain mới → reconcile session/worklet
// (start nếu chain active khi play / stop nếu chain bị bypass / re-splice
// nếu mods đổi). VST áp SAU builtin FX — khớp offline export.
fxRtTrackReconcile(trackId, node, track);
} catch (e) {
console.warn('rebuildTrackFxGraph error:', e);
}
@@ -23138,6 +23344,143 @@ const App = () => {
rebuildTrackFxGraphRef.current = rebuildTrackFxGraph;
window.__rebuildTrackFxGraph = rebuildTrackFxGraph;
// ── Realtime VST FX per-track (SHM bridge) — component scope ──────────────
// Cần activeTrackNodesRef/isPlayingRef. Session giống master (fxRtStart):
// fetch start → AudioWorkletNode 'sf-fx-realtime' → WS; WS open → splice
// worklet vào chain hiện tại (đọc mods MỚI NHẤT — an toàn với rebuild).
const fxRtTrackStart = async (trackId, track, path) => {
const key = trackId + ':' + path;
const chain = fxRtTrackChain(track);
if (!chain.length) return;
if (fxRtTracks[key]) {
if (!fxRtTracks[key].node || fxRtTracks[key].node.context !== getAudioContext()) fxRtTrackStop(trackId, path, true);
else return;
}
const ctx = getAudioContext();
if (!ctx || !ctx.audioWorklet) return;
const chainPayload = chain.map(s => ({ type: 'vst3', path: s.path, name: s.name || s.path, preset_b64: s.preset_b64 || '', bypass: false }));
let st;
try {
st = await window.SonicAPI.fxRealtimeStart({ fx_chain: chainPayload, sample_rate: ctx.sampleRate || 44100 });
} catch (e) {
console.warn('[FX RT track] start fail', trackId, path, e);
return;
}
const entry = { sessionId: st.session_id, ws: null, node: null, ready: false, path, spliced: null, chainKey: JSON.stringify(chainPayload), inQueue: [], pumpTimer: null };
fxRtTracks[key] = entry;
try {
await ctx.audioWorklet.addModule(`${API_BASE_URL}/static/js/sf-fx-realtime.js?v=202608212200`);
const node = new AudioWorkletNode(ctx, 'sf-fx-realtime', { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [2] });
entry.node = node;
const u = new URL(API_BASE_URL);
const wsProto = u.protocol === 'https:' ? 'wss://' : 'ws://';
const tok = localStorage.getItem('sonic_token') || '';
const ws = new WebSocket(`${wsProto}${u.host}${st.ws_url}${tok ? '?token=' + encodeURIComponent(tok) : ''}`);
entry.ws = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => { entry.ready = true; fxRtTrackSplice(trackId, path); };
ws.onmessage = (ev) => {
if (!(ev.data instanceof ArrayBuffer) || !entry.node) return;
const f = new Float32Array(ev.data);
const half = f.length / 2;
const l = new Float32Array(half);
const r = new Float32Array(half);
for (let i = 0; i < half; i++) { l[i] = f[i * 2]; r[i] = f[i * 2 + 1]; }
entry.node.port.postMessage({ type: 'out', l, r });
};
ws.onclose = () => fxRtTrackStop(trackId, path, true);
ws.onerror = () => { try { ws.close(); } catch (e) {} };
entry.node.port.onmessage = (ev) => {
const d = ev.data;
if (!d || d.type !== 'in' || !entry.ws) return;
if (entry.inQueue.length > 64) entry.inQueue.shift();
entry.inQueue.push(d.data);
};
const blockMs = Math.max(4, Math.round((256 / (ctx.sampleRate || 44100)) * 1000));
entry.pumpTimer = setInterval(() => {
const ws = entry.ws;
if (!ws || ws.readyState !== 1 || !entry.inQueue.length) return;
const d = entry.inQueue.shift();
try { ws.send(d); } catch (e) { entry.inQueue.unshift(d); }
}, blockMs);
} catch (e) {
console.warn('[FX RT track] setup fail', trackId, path, e);
fxRtTrackStop(trackId, path, true);
}
};
const fxRtTrackUnsplice = (trackId, path) => {
const entry = fxRtTracks[trackId + ':' + path];
if (!entry || !entry.spliced || !entry.node) return;
try {
entry.node.disconnect();
try { entry.spliced.tail.connect(entry.spliced.dest); } catch (e) {}
entry.spliced = null;
} catch (e) { console.warn('[FX RT track] unsplice fail', trackId, path, e); }
};
const fxRtTrackSplice = (trackId, path) => {
const key = trackId + ':' + path;
const entry = fxRtTracks[key];
const node = activeTrackNodesRef.current[trackId];
if (!entry || !entry.ready || !entry.node || !node || entry.spliced) return;
const tail = path === 'audio'
? (node.fxMods && node.fxMods.length ? node.fxMods[node.fxMods.length - 1].output : node.fxEntry)
: (node.sfMods && node.sfMods.length ? node.sfMods[node.sfMods.length - 1].output : node.sfEntry);
const dest = path === 'audio' ? node.fxLegacyIn : node.sfOut;
if (!tail || !dest) return;
try {
tail.disconnect(dest);
entry.node.connect(dest);
tail.connect(entry.node);
entry.spliced = { tail, dest };
console.log('[FX RT track] spliced', trackId, path);
} catch (e) { console.warn('[FX RT track] splice fail', trackId, path, e); }
};
const fxRtTrackStop = (trackId, path, silent) => {
const key = trackId + ':' + path;
const entry = fxRtTracks[key];
if (!entry) return;
if (entry.pumpTimer) { clearInterval(entry.pumpTimer); entry.pumpTimer = null; }
entry.inQueue = [];
try {
fxRtTrackUnsplice(trackId, path);
if (entry.node) { entry.node.port.onmessage = null; try { entry.node.disconnect(); } catch (e) {} entry.node = null; }
} catch (e) {}
try { if (entry.ws) { entry.ws.onclose = null; entry.ws.onmessage = null; try { entry.ws.close(); } catch (e) {} entry.ws = null; } } catch (e) {}
const sid = entry.sessionId;
delete fxRtTracks[key];
if (sid) {
try { window.SonicAPI.fxRealtimeStop({ session_id: sid }).catch(() => {}); } catch (e) {}
}
};
const fxRtTrackReconcile = (trackId, node, track) => {
try {
const chain = fxRtTrackChain(track);
const wantPaths = chain.length ? fxRtTrackPaths(track) : [];
const prefix = trackId + ':';
Object.keys(fxRtTracks).forEach(k => {
if (!k.startsWith(prefix)) return;
const p = k.slice(prefix.length);
if (wantPaths.indexOf(p) < 0) fxRtTrackStop(trackId, p, true);
});
if (!isPlayingRef.current) return;
wantPaths.forEach(p => {
const key = trackId + ':' + p;
const entry = fxRtTracks[key];
if (!entry) fxRtTrackStart(trackId, track, p);
else if (entry.ready && node) {
fxRtTrackUnsplice(trackId, p); // chain mới (sau rebuild) — splice lại đúng mods
fxRtTrackSplice(trackId, p);
}
});
} catch (e) { console.warn('fxRtTrackReconcile error:', e); }
};
const fxRtTrackStopAll = () => {
Object.keys(fxRtTracks).forEach(k => {
const i = k.lastIndexOf(':');
fxRtTrackStop(k.slice(0, i), k.slice(i + 1), true);
});
};
// Route the SHARED FluidSynth output through the gainNode of the single
// audible MIDI track so its FX Rack chain / fader / pan affect the soundfont
// instrument (unified_fx_rack_panel.md + user request). With more than one
@@ -23546,6 +23889,8 @@ const App = () => {
const context = getAudioContext();
_nativePlayEpoch++; // new play — vô hiệu doHardStop đang fade (play trong cửa sổ 80ms)
restorePendingFadeSession(); // V17: huy fade dang treo — replay khong bi dip gain
// Realtime VST FX (master chain qua SHM bridge): bật khi play + chain active.
try { fxRtStart(); } catch (e) { console.warn('[FX RT] start error:', e); }
// D2: bridge active -> báo transport PLAY (bridge flush note-off cũ + đồng
// bộ timeline; A13 C++ xử lý arg1=playheadSamples sau này).
if (window.NativeBridgeService && window.NativeBridgeService.isBridgeConnected) {
@@ -23578,6 +23923,14 @@ const App = () => {
if (!isPlayable) return;
const gainNode = getOrCreateTrackNode(track, context);
// Realtime VST FX per-track (SHM bridge): mọi luồng track qua bridge —
// session/worklet cho TỪNG path có âm (audio-clips + MIDI/SF); splice
// vào graph khi WS ready.
try {
const rtPaths = fxRtTrackPaths(track);
if (rtPaths.indexOf('audio') >= 0) fxRtTrackStart(track.id, track, 'audio');
if (rtPaths.indexOf('sf') >= 0) fxRtTrackStart(track.id, track, 'sf');
} catch (e) { console.warn('[FX RT track] start error:', e); }
const pannerNode = activeTrackNodesRef.current[track.id].pannerNode;
// Re-apply route gains theo content-type NGAY tại thời điểm play — node
// có thể STALE (tạo từ MAIN play với track cùng id — route theo data cũ)
@@ -24203,6 +24556,10 @@ const App = () => {
const stopAllPlayback = (fade) => {
try {
isPlayingRef.current = false;
// Realtime VST FX: dừng session + khôi phục output→analyser.
try { fxRtStop(); } catch (e) { console.warn('[FX RT] stop error:', e); }
// Realtime VST FX per-track: dừng mọi session track + restore chain trực tiếp.
try { fxRtTrackStopAll(); } catch (e) { console.warn('[FX RT track] stop error:', e); }
_nativePlayEpoch++; // hủy mọi render/decode pending của lần play cũ
// V11: hủy mọi note timer của lần play cũ (stop/seek/loop restart) — timer
// cũ bắn vào vòng mới = NOTE_OFF cắt note đang kêu → crack (xem ref).
@@ -26936,9 +27293,24 @@ const App = () => {
else if (m.startTime) needDur = Math.max(needDur, m.startTime + (m.duration || 8));
});
});
// Cache có thể "đủ" (chunks + duration) nhưng RỖNG — khi bridge VSTi active,
// âm đi qua BridgeAudioNode, KHÔNG qua track.sfEntry nơi ensureMidiCapture
// tap → chunks toàn zero → export offline ra file câm tuyệt đối. Kiểm tra
// năng lượng cache: nếu không có tín hiệu → bounce realtime (bắt đúng âm bridge).
const cacheHasEnergy = (c) => {
if (!c) return false;
const chunks = c.chunks || [];
for (let i = 0; i < chunks.length; i++) {
const ch = chunks[i];
for (let j = 0; j < ch.length; j++) {
if (Math.abs(ch[j]) > 0.0005) return true;
}
}
return false;
};
const cacheOK = midiTracks.length > 0 && midiTracks.every(t => {
const c = midiCacheRef.current[t.id];
return c && c.chunks.length > 0 && c.duration >= needDur - 0.3;
return c && c.chunks.length > 0 && c.duration >= needDur - 0.3 && cacheHasEnergy(c);
});
if (cacheOK) {
showToast("Dùng MIDI cache từ preview — export OFFLINE NHANH (vẫn đủ FX Rack + Mastering Chain).", "info");
@@ -27417,6 +27789,75 @@ const App = () => {
if (c && !loopActive && c.duration > durationLimit) durationLimit = c.duration;
});
const outChannels = exportSettings.channels === 'mono' ? 1 : 2;
const scheduleTrackInto = (t, ctx, nodes, directOut) => {
const node = buildOfflineTrackNode(t, ctx, nodes, { directOut });
if (!node) return null;
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
id: 'default',
buffer: t.buffer,
startTime: t.startTime || 0,
name: t.name,
speed: t.speed || 1.0
}] : [];
clips.forEach(clip => {
if (!clip.buffer) return;
const source = ctx.createBufferSource();
source.buffer = clip.buffer;
source.playbackRate.value = clip.speed || 1.0;
source.connect(node.gainNode);
const clipStart = clip.startTime || 0;
source.start(clipStart);
});
// MIDI preview cache → schedule through the SF chain (track FX + mastering)
if (node.sfEntry) {
const midiBuf = midiCacheToBuffer(t.id);
if (midiBuf) {
let mSrcBuf = midiBuf;
if (loopInfo && loopInfo.loopDur) {
const ln = Math.min(midiBuf.length, Math.max(1, Math.ceil(loopInfo.loopDur * midiBuf.sampleRate)));
mSrcBuf = ctx.createBuffer(midiBuf.numberOfChannels, ln, midiBuf.sampleRate);
for (let mc = 0; mc < midiBuf.numberOfChannels; mc++) mSrcBuf.copyToChannel(midiBuf.getChannelData(mc).subarray(0, ln), mc);
}
const mSrc = ctx.createBufferSource();
mSrc.buffer = mSrcBuf;
mSrc.connect(node.sfEntry);
mSrc.start(0);
}
}
return node;
};
// VST FX THẬT qua fx_vst_bridge (engine /fx-render): encode WAV float32 →
// upload → bridge render → download → decode. Lỗi → giữ nguyên PCM (export
// vẫn chạy, toast cảnh báo) — không chặn export.
const applyVstChainToPcm = async (pcm, rate, chain, label) => {
const active = activeVstChain(chain);
if (!active.length) return pcm;
try {
const blob = encodeFloatWavBlob(pcm, rate, 2);
const fd = new FormData();
fd.append('file', blob, 'vst_fx_input.wav');
const up = await fetch(`${API_AUDIO}/upload`, { method: 'POST', body: fd });
if (!up.ok) throw new Error('upload VST input thất bại (HTTP ' + up.status + ')');
const upData = await up.json();
const fxChain = active.map(s => ({ type: 'vst3', path: s.path, name: s.name || s.path, preset_b64: s.preset_b64 || '', bypass: false }));
const r = await window.SonicAPI.fxRender({ input_file_id: upData.file_id, fx_chain: fxChain, sample_rate: rate });
if (!r || !r.url) throw new Error('fx-render không trả url');
const resp = await fetch(`${API_BASE_URL}${r.url}`);
if (!resp.ok) throw new Error('download VST output thất bại (HTTP ' + resp.status + ')');
const ab = await resp.arrayBuffer();
const dctx = new OfflineAudioContext(2, 1, rate);
const audioBuf = await dctx.decodeAudioData(ab);
try { if (window.SonicAPI.deleteMyFile) window.SonicAPI.deleteMyFile(upData.file_id); } catch (e) { }
return audioBufferToInterleaved(audioBuf).pcm;
} catch (e) {
console.warn('[VST FX]', label, 'không áp dụng:', e);
showToast(`VST FX "${label}" không áp dụng được: ${e.message || e} — export không có VST này.`, 'warning');
return pcm;
}
};
const trackVstChains = activeTracks.filter(t => activeVstChain(t.vstFxChain || []).length > 0);
const masterVstChain = activeVstChain((masteringSettings && masteringSettings.vstFxChain) || []);
const masterVstOn = masterVstChain.length > 0 && !(masteringSettings && masteringSettings.vstBypass);
const offlineCtx = new OfflineAudioContext(outChannels, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate);
// Build the FULL graph (per-track FX Rack chains + mastering chain — the
// same DSP as playback), so the exported file matches what you hear.
@@ -27439,43 +27880,60 @@ const App = () => {
masterBus.output.gain.setValueAtTime(mv <= -50 ? 0 : Math.pow(10, mv / 20), 0);
}
} catch (e) { console.warn('offline main-out volume error:', e); }
activeTracks.forEach(t => {
const node = buildOfflineTrackNode(t, offlineCtx, offlineNodes);
if (!node) return;
const clips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
id: 'default',
buffer: t.buffer,
startTime: t.startTime || 0,
name: t.name,
speed: t.speed || 1.0
}] : [];
clips.forEach(clip => {
if (!clip.buffer) return;
const source = offlineCtx.createBufferSource();
source.buffer = clip.buffer;
source.playbackRate.value = clip.speed || 1.0;
source.connect(node.gainNode);
const clipStart = clip.startTime || 0;
source.start(clipStart);
});
// MIDI preview cache → schedule through the SF chain (track FX + mastering)
if (node.sfEntry) {
const midiBuf = midiCacheToBuffer(t.id);
if (midiBuf) {
let mSrcBuf = midiBuf;
if (loopInfo && loopInfo.loopDur) {
const ln = Math.min(midiBuf.length, Math.max(1, Math.ceil(loopInfo.loopDur * midiBuf.sampleRate)));
mSrcBuf = offlineCtx.createBuffer(midiBuf.numberOfChannels, ln, midiBuf.sampleRate);
for (let mc = 0; mc < midiBuf.numberOfChannels; mc++) mSrcBuf.copyToChannel(midiBuf.getChannelData(mc).subarray(0, ln), mc);
}
const mSrc = offlineCtx.createBufferSource();
mSrc.buffer = mSrcBuf;
mSrc.connect(node.sfEntry);
mSrc.start(0);
}
let renderedBuffer;
if (trackVstChains.length > 0 || masterVstOn) {
// Export có VST FX → render TRACK RIÊNG (không mastering) → áp track
// VST chain qua fx_vst_bridge → cộng mix → mastering built-in 1 lần
// trên tổng → master VST chain cuối chain. Đúng thứ tự live: track fx
// → (sum) → master fx → out.
let mix = null;
for (const t of activeTracks) {
const chain = activeVstChain(t.vstFxChain || []);
const tctx = new OfflineAudioContext(2, Math.ceil(targetRate * Math.max(0.1, durationLimit)), targetRate);
const tNodes = {};
const node = scheduleTrackInto(t, tctx, tNodes, true);
if (!node) continue;
const tb = await tctx.startRendering();
try { if (node.fxStopFn) node.fxStopFn(); } catch (e) { }
let pcm = audioBufferToInterleaved(tb).pcm;
if (chain.length) pcm = await applyVstChainToPcm(pcm, targetRate, chain, t.name || 'track');
mix = mix ? mixInterleaved(mix, pcm) : pcm;
}
});
const renderedBuffer = await offlineCtx.startRendering();
if (!mix) mix = new Float32Array(Math.ceil(targetRate * Math.max(0.1, durationLimit)) * 2);
const mctx = new OfflineAudioContext(2, Math.floor(mix.length / 2), targetRate);
masterBus = null;
initMasterBus(mctx);
try {
toggleMasteringOnMaster(!!(masteringSettings && masteringSettings.masterConnected), !!(masteringSettings && masteringSettings.isBypassed));
} catch (e) { console.warn('offline mastering toggle error (vst):', e); }
try {
if (masterBus && masterBus.output) {
const mv = (typeof masterVolume === 'number' && isFinite(masterVolume)) ? masterVolume : 0;
masterBus.output.gain.setValueAtTime(mv <= -50 ? 0 : Math.pow(10, mv / 20), 0);
}
} catch (e) { console.warn('offline main-out volume error (vst):', e); }
const src = mctx.createBufferSource();
src.buffer = interleavedToAudioBuffer(mix, targetRate, 2, mctx);
src.connect(masterBus.input);
src.start(0);
renderedBuffer = await mctx.startRendering();
if (masterVstOn) {
const conv = audioBufferToInterleaved(renderedBuffer);
let outPcm = await applyVstChainToPcm(conv.pcm, targetRate, masterVstChain, 'master');
if (outChannels === 1) {
const n = Math.floor(outPcm.length / 2);
const mono = new Float32Array(n);
for (let i = 0; i < n; i++) mono[i] = (outPcm[i * 2] + outPcm[i * 2 + 1]) * 0.5;
outPcm = mono;
}
renderedBuffer = interleavedToAudioBuffer(outPcm, targetRate, outChannels, mctx);
}
} else {
activeTracks.forEach(t => {
scheduleTrackInto(t, offlineCtx, offlineNodes, false);
});
renderedBuffer = await offlineCtx.startRendering();
}
const numExportCh = renderedBuffer.numberOfChannels;
const exportLength = renderedBuffer.length;
const bytesPerSample = bitDepth / 8;
@@ -27590,9 +28048,26 @@ const App = () => {
}
}
} finally {
// Restore the live master bus + live mastering state after offline render.
masterBus = savedMasterBus;
try { if (savedMasterBus) toggleMasteringOnMaster(!!(masteringSettings && masteringSettings.masterConnected), !!(masteringSettings && masteringSettings.isBypassed)); } catch (e) { }
// Restore the LIVE master bus after offline render: initMasterBus(offlineCtx)
// ghi đè cả `masterBus` lẫn `window.masterBus` bằng graph OfflineAudioContext
// (state closed) → track route (window.masterBus.input) đổ vào context câm →
// play/bounce sau export CÂM. Khôi phục bus live (giữ wiring cũ nếu còn sống,
// ngược lại rebuild graph trên audioCtx thật) và luôn trả lại window.masterBus.
try {
const liveCtx = audioCtx;
let restored = savedMasterBus;
if (!(restored && liveCtx && restored.output && restored.output.context === liveCtx && liveCtx.state !== 'closed')) {
masterBus = null;
initMasterBus(liveCtx || getAudioContext());
restored = masterBus;
}
masterBus = restored;
window.masterBus = restored;
} catch (e) {
masterBus = savedMasterBus;
window.masterBus = savedMasterBus;
}
try { toggleMasteringOnMaster(!!(masteringSettings && masteringSettings.masterConnected), !!(masteringSettings && masteringSettings.isBypassed)); } catch (e) { }
Object.keys(offlineNodes).forEach(k => { try { if (offlineNodes[k].fxStopFn) offlineNodes[k].fxStopFn(); } catch (e) { } });
}
} catch (err) {
File diff suppressed because one or more lines are too long
+4
View File
@@ -92,6 +92,10 @@ window.API_BASE_URL = (window.API_BASE_URL || window.location.origin).replace(/\
midiRender: (payload) => apiRequest('/api/v1/plugins/midi-render', { method: 'POST', body: JSON.stringify(payload) }),
listFx: () => apiRequest('/api/v1/plugins/fx', { method: 'GET' }),
fxRender: (payload) => apiRequest('/api/v1/plugins/fx-render', { method: 'POST', body: JSON.stringify(payload) }),
// Realtime VST FX qua SHM bridge (master chain): start session → WS
// /ws/fx-realtime/{id} đẩy/kéo block audio; stop → tắt bridge.
fxRealtimeStart: (payload) => apiRequest('/api/v1/plugins/fx-realtime/start', { method: 'POST', body: JSON.stringify(payload) }),
fxRealtimeStop: (payload) => apiRequest('/api/v1/plugins/fx-realtime/stop', { method: 'POST', body: JSON.stringify(payload) }),
// Mở native GUI của VST FX (mastering chain / FX rack) qua bridge
openFxGui: (payload) => apiRequest('/api/v1/plugins/fx-gui', { method: 'POST', body: JSON.stringify(payload) }),
soundfontRender: (payload) => apiRequest('/api/v1/plugins/soundfont-render', { method: 'POST', body: JSON.stringify(payload) }),
+75
View File
@@ -0,0 +1,75 @@
// sf-fx-realtime.js — AudioWorkletProcessor nối WebAudio → SHM FX engine.
// process() gom 128-sample quantum thành block 256 stereo float32 (khớp
// FXRT_BLOCK trong FxRealtimeIPC.h) → postMessage cho main thread gửi WS;
// nhận block đã xử lý từ main thread → queue → phát ra output. Queue rỗng →
// zero fill (WS chưa kịp round-trip).
//
// Latency ~2 block; backlog > 32 block (~190ms) → drop block cũ nhất. Cap cao
// hấp thụ jitter main thread (rAF renderFrame nặng khi mở MASTERING PANEL) mà
// không drop. ponytail: thay drop bằng adaptive stretch khi cần chất lượng cao.
class SfFxRealtimeProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.BLOCK = 256; // FXRT_BLOCK
this.inL = new Float32Array(this.BLOCK);
this.inR = new Float32Array(this.BLOCK);
this.inCount = 0;
this.outQueue = []; // [{l: Float32Array, r: Float32Array}]
this.outPos = 0;
this.port.onmessage = (e) => {
const d = e.data;
if (!d || d.type !== 'out' || !d.l || !d.r) return;
this.outQueue.push({ l: d.l, r: d.r });
while (this.outQueue.length > 32) this.outQueue.shift();
};
}
process(inputs, outputs) {
const input = inputs[0];
const output = outputs[0];
const n = output[0] ? output[0].length : 128;
const outL = output[0];
const outR = output[1];
// Góp input thành block 256 → gửi main thread (transferable buffer).
if (input && input[0]) {
const inL = input[0];
const inR = input[1] || input[0];
for (let i = 0; i < n; i++) {
if (this.inCount < this.BLOCK) {
this.inL[this.inCount] = inL[i];
this.inR[this.inCount] = inR[i];
}
this.inCount++;
if (this.inCount >= this.BLOCK) {
const inter = new Float32Array(this.BLOCK * 2);
for (let j = 0; j < this.BLOCK; j++) {
inter[j * 2] = this.inL[j];
inter[j * 2 + 1] = this.inR[j];
}
this.port.postMessage({ type: 'in', data: inter.buffer }, [inter.buffer]);
this.inCount = 0;
}
}
}
// Phát output từ queue; rỗng → zero.
for (let i = 0; i < n; i++) {
if (this.outQueue.length === 0) {
outL[i] = 0;
if (outR) outR[i] = 0;
continue;
}
const blk = this.outQueue[0];
outL[i] = blk.l[this.outPos];
if (outR) outR[i] = blk.r[this.outPos];
this.outPos++;
if (this.outPos >= this.BLOCK) {
this.outQueue.shift();
this.outPos = 0;
}
}
return true;
}
}
registerProcessor('sf-fx-realtime', SfFxRealtimeProcessor);