V45.46: GUI embed VST full-panel trong DAW - phuc hoi embed (frame-capture ~10fps, relay input), bo toolbar header 'X Dong GUI' trong VstGuiEmbed + bo bar FX RT telemetry trong Mastering (phan tach GUI vs chain FX slot = border chain strip); dong GUI = chain card / module builtin / chon VST khac / onDead; projects API /disk list+open + api.js wrappers; plugins.py them SF_FXGUI_FLOAT=1 khi spawn bridge GUI; FxGuiServer/RenderFxJob fix GUI embed frame capture + show/hide re-anchor theo panel rect. Rebuild daw_vst_bridge+fx_vst_bridge+plugin_host v=202609032200.

This commit is contained in:
2026-09-03 21:28:20 +07:00
parent db9123438d
commit 6a74631cba
14 changed files with 713 additions and 221 deletions
+270 -98
View File
@@ -8293,7 +8293,7 @@ const SaveProjectModal = ({
])));
};
const OpenProjectModal = ({ isOpen, onClose, onOpenCloud, onOpenLocal }) => {
const OpenProjectModal = ({ isOpen, onClose, onOpenCloud, onOpenLocal, onOpenDisk }) => {
if (!isOpen) return null;
const [tab, setTab] = useState('cloud');
const [projects, setProjects] = useState([]);
@@ -8306,6 +8306,12 @@ const OpenProjectModal = ({ isOpen, onClose, onOpenCloud, onOpenLocal }) => {
if (api && api.listCloudProjects) {
api.listCloudProjects().then(function(data) { setProjects(data || []); }).catch(function() { setProjects([]); }).finally(function() { setLoading(false); });
} else { setLoading(false); }
} else if (tab === 'disk') {
setLoading(true);
var api2 = window.SonicAPI;
if (api2 && api2.listDiskProjects) {
api2.listDiskProjects().then(function(data) { setProjects(data || []); }).catch(function() { setProjects([]); }).finally(function() { setLoading(false); });
} else { setLoading(false); }
}
}, [isOpen, tab]);
return React.createElement("div", {
@@ -8315,12 +8321,16 @@ const OpenProjectModal = ({ isOpen, onClose, onOpenCloud, onOpenLocal }) => {
}, React.createElement("h3", {
className: "text-sm font-bold text-teal-400 mb-4 uppercase"
}, "Mở dự án"), React.createElement("div", {
className: "grid grid-cols-2 gap-2 text-xs mb-4"
className: "grid grid-cols-3 gap-2 text-xs mb-4"
}, [
React.createElement("button", {
key: "cloud-tab", type: "button", onClick: function() { setTab('cloud'); },
className: "py-2 rounded border flex flex-col items-center gap-1 font-semibold transition " + (tab === 'cloud' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400')
}, [React.createElement("span", { key: "t" }, "☁️ Cloud"), React.createElement("span", { key: "d", className: "text-[9px] font-normal text-zinc-500" }, "Dự án trên server")]),
React.createElement("button", {
key: "disk-tab", type: "button", onClick: function() { setTab('disk'); },
className: "py-2 rounded border flex flex-col items-center gap-1 font-semibold transition " + (tab === 'disk' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400')
}, [React.createElement("span", { key: "t" }, "📁 Trên máy"), React.createElement("span", { key: "d", className: "text-[9px] font-normal text-zinc-500" }, "Đã lưu Ctrl-S / Lưu Local")]),
React.createElement("button", {
key: "local-tab", type: "button", onClick: function() { setTab('local'); },
className: "py-2 rounded border flex flex-col items-center gap-1 font-semibold transition " + (tab === 'local' ? 'bg-teal-950/60 border-teal-500 text-teal-300' : 'bg-zinc-800 border-zinc-700 hover:bg-zinc-750 text-zinc-400')
@@ -8347,6 +8357,27 @@ const OpenProjectModal = ({ isOpen, onClose, onOpenCloud, onOpenLocal }) => {
]);
})
)
: tab === 'disk' ?
React.createElement("div", { className: "space-y-1.5 max-h-64 overflow-y-auto" },
loading ? [React.createElement("div", { key: "l", className: "text-center py-8 text-xs text-zinc-500" }, "Đang tải danh sách dự án trên máy...")]
: projects.length === 0 ? [React.createElement("div", { key: "e", className: "text-center py-8 text-xs text-zinc-500" }, "Chưa có dự án nào trong Documents/SonicForgeDAW/Projects. Nhấn Ctrl+S (Save) để lưu vào đây.")]
: projects.map(function(p) {
return React.createElement("div", {
key: p.filename,
onClick: function() { onOpenDisk(p.filename, p.name); },
className: "flex items-center justify-between p-3 bg-[#1e1e1e] hover:bg-zinc-800 rounded border border-zinc-800 transition cursor-pointer text-xs group"
}, [
React.createElement("div", { key: "meta" }, [
React.createElement("div", { className: "font-bold text-slate-200 group-hover:text-teal-400" }, p.name),
React.createElement("div", { className: "text-[10px] text-zinc-500 mt-0.5" }, "Lưu lúc: " + new Date((p.mtime || 0) * 1000).toLocaleString())
]),
React.createElement("button", {
onClick: function(e) { e.stopPropagation(); onOpenDisk(p.filename, p.name); },
className: "px-2.5 py-1 bg-teal-800/80 hover:bg-teal-700 text-teal-200 rounded font-semibold text-[10px]"
}, "MỞ")
]);
})
)
: React.createElement("div", { className: "py-4 text-center text-xs text-zinc-400 space-y-3" },
[React.createElement("div", { key: "d", className: "text-zinc-500" }, "Chọn tệp .sfs để mở dự án từ Local."),
React.createElement("button", {
@@ -8360,7 +8391,6 @@ const OpenProjectModal = ({ isOpen, onClose, onOpenCloud, onOpenLocal }) => {
className: "px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded"
}, "Hủy"))));
};
const SaveAsModal = ({
isOpen,
onClose,
@@ -12884,14 +12914,33 @@ const InteractiveEqPro = ({ track, params, onChange, getModule, applyTo, spectru
raf = requestAnimationFrame(draw);
const cv = canvasRef.current;
if (!cv || !cv.clientWidth) return;
const s = st.current;
const specMods = (spectrumModules ? spectrumModules() : null) || (getModule ? [getModule()] : []);
// V45.46 throttle: đang kéo band hoặc có tín hiệu live → full 60fps; idle
// (không kéo, analyser im) → chỉ vẽ ~mỗi 12 frame — curve tĩnh không cần
// redraw 60fps (canvas EQ trong FXRack/Mastering modal tốn GPU khi mở).
if (s.dragId === null) {
let live = false;
for (const _m of specMods) {
const _an = _m && _m.analyser;
if (!_an) continue;
try {
if (!s._freq || s._freq.length !== _an.frequencyBinCount) s._freq = new Uint8Array(_an.frequencyBinCount);
_an.getByteFrequencyData(s._freq);
for (let _i = 0; _i < s._freq.length; _i++) { if (s._freq[_i] > 4) { live = true; break; } }
} catch (e) {}
if (live) break;
}
s._frame = (s._frame || 0) + 1;
if (!live && s._frame % 12 !== 1) return;
}
const w = cv.clientWidth, h = cv.clientHeight;
if (cv.width !== w * 2) { cv.width = w * 2; cv.height = h * 2; }
const c = cv.getContext('2d');
c.setTransform(2, 0, 0, 2, 0, 0);
// Spectrum sources: every live module analyser (audioclip path + soundfont
// path) is overlaid, so playing audio clips OR midi both show up.
const specMods = (spectrumModules ? spectrumModules() : null) || (getModule ? [getModule()] : []);
renderEqProFrame(c, w, h, st.current, specMods, (typeof getAudioContext === 'function' && getAudioContext()) ? getAudioContext().sampleRate : 44100);
renderEqProFrame(c, w, h, s, specMods, (typeof getAudioContext === 'function' && getAudioContext()) ? getAudioContext().sampleRate : 44100);
};
draw();
return () => cancelAnimationFrame(raf);
@@ -13368,74 +13417,63 @@ const ExportProgressModal = ({ open, exportProgress, isExporting, onClose }) =>
);
};
// --- Embedded VST GUI (PLAN_MASTERBUS_VST_GUI_EMBED.md) --------------------
// Frame-capture + input relay: hiển thị native GUI qua HTTP /frame (~10fps),
// gửi pointer/key về bridge qua /input. Dùng chung MasteringModal + FXRackModal.
// url = embed_url từ API fx-gui open (embed:true); onClose khi user đóng;
// onDead khi bridge chết (ping fail) → parent trả graph + toast.
// --- Embedded VST GUI (frame-capture embed) ------------------------------------
// Native plugin window duoc park de len dung rect cua panel nay (POST /show
// kem screen rect, refresh ~250ms de re-anchor khi scroll/resize); pixel cua
// no duoc hien trong panel qua JPEG frame capture (GET /frame, ~10fps), su
// kien chuot/ban phim relay bang POST /input — khong con cua so noi, khong
// can OS focus. Dong panel chi POST /hide (bridge + plugin con song, mo lai
// nhanh); dong that su (POST /close) tu closeFxGui/closeMasterGui khi chon
// module khac, hoac tu dong khi onDead (bridge chet). Khong con toolbar.
const VstGuiEmbed = ({ url, onClose, onDead }) => {
const imgRef = React.useRef(null);
const preloadRef = React.useRef(null);
const lastShowRef = React.useRef(null);
const boxRef = React.useRef(null);
const deadRef = React.useRef(false);
const screenRectRef = React.useRef(null);
const [, setDead] = React.useState(false);
const [frameLoaded, setFrameLoaded] = React.useState(false);
const frameLoadedRef = React.useRef(false);
const post = (payload) => { try { fetch(url + '/input', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).catch(() => {}); } catch (e) {} };
const post = payload => {
try { fetch(url + '/input', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).catch(() => {}); } catch (e) {}
};
// Poll frame capture ~100ms + ping 2s de phat hien bridge chet.
React.useEffect(() => {
deadRef.current = false;
setDead(false);
setFrameLoaded(false);
frameLoadedRef.current = false;
const tick = setInterval(() => {
const img = imgRef.current;
if (!img || deadRef.current) return;
const pr = preloadRef.current;
if (pr && !pr.complete) return; // frame đang tải dở → chờ, không spam /frame
const np = pr || new Image();
np.onload = () => { if (preloadRef.current === np) { preloadRef.current = null; img.src = np.src; } };
np.onerror = () => { if (preloadRef.current === np) preloadRef.current = null; }; // frame lỗi → giữ frame cũ, không flash/trắng
np.src = url + '/frame?t=' + Date.now();
preloadRef.current = np;
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(); }
if (!deadRef.current) { deadRef.current = true; onDead && onDead(); }
});
}, 2000);
// Show immediately on mount even before the rect is known: bridge
// re-shows the native window at its last position, and the ~500ms
// rect refresh below re-anchors it over the panel.
// Show ngay khi mount, truoc khi co rect: bridge hien lai native window
// o vi tri cu; ~250ms sau rect refresh ben duoi se re-anchor de len panel.
try { fetch(url + '/show', { method: 'POST' }).catch(() => {}); } catch (e) {}
// Watchdog: plugin load is allowed up to 90s; if no frame arrived and
// the bridge did not die (ping alive = process stuck in plugin init/attach),
// give up so the panel does not flash "Đang tải plugin VST..." forever.
const watchdog = setTimeout(() => {
if (!deadRef.current && !frameLoadedRef.current) {
deadRef.current = true; setDead(true); onDead && onDead();
}
}, 90000);
return () => {
clearInterval(tick); clearInterval(ping); clearTimeout(watchdog); preloadRef.current = null;
// Panel closed -> hide the native window (bridge stays alive so
// reopening is fast); POST /hide chứ không /close.
clearInterval(tick);
clearInterval(ping);
// Panel dong -> hide native window (bridge con song, mo lai nhanh).
// Dong that su (POST /close + teardown engine) tu closeFxGui/closeMasterGui
// khi chon module/chain khac, hoac tu dong khi onDead (bridge chet).
try { fetch(url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// Embed panel screen rect (physical px) for the bridge's anchored real-input
// dance: Tauri innerPosition + scaleFactor; window.screenX/Y fallback when
// not running under Tauri. Refreshed ~500ms so scroll/resize stays current.
// Screen rect (physical px) cua panel cho bridge re-anchor + real-input:
// Tauri innerPosition + scaleFactor; fallback window.screenX/Y khi khong
// chay trong Tauri. Refresh ~250ms de theo scroll/resize.
React.useEffect(() => {
let alive = true;
const maybeShow = () => {
const r = screenRectRef.current;
if (r && r.w >= 4 && r.h >= 4) {
const key = Math.round(r.x) + ',' + Math.round(r.y) + ',' + Math.round(r.w) + ',' + Math.round(r.h);
if (lastShowRef.current === key) return; // rect không đổi → không spam /show
lastShowRef.current = key;
// Re-POST /show moi lan refresh de native window re-anchor theo panel
// khi scroll/drag/resize. Idempotent: bridge skip SetWindowPos khi
// khong co gi doi.
try { fetch(url + '/show', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rect: r }) }).catch(() => {}); } catch (e) {}
}
};
@@ -13469,18 +13507,17 @@ const ExportProgressModal = ({ open, exportProgress, isExporting, onClose }) =>
return () => { alive = false; clearInterval(iv); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
const pt = (e) => {
const pt = e => {
const box = boxRef.current, img = imgRef.current;
if (!box || !img || !img.naturalWidth || !img.naturalHeight) return null;
const r = box.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return null;
return {
x: Math.round((e.clientX - r.left) / r.width * img.naturalWidth),
y: Math.round((e.clientY - r.top) / r.height * img.naturalHeight)
};
return { x: Math.round((e.clientX - r.left) / r.width * img.naturalWidth), y: Math.round((e.clientY - r.top) / r.height * img.naturalHeight) };
};
const handle = (e) => {
const p = pt(e); if (!p) return;
const handle = e => {
const p = pt(e);
if (!p) return;
post({ type: e.type === 'mousedown' ? 'mousedown' : e.type === 'mouseup' ? 'mouseup' : 'mousemove', x: p.x, y: p.y, button: e.button, buttons: e.buttons, rect: screenRectRef.current || undefined });
};
return (
@@ -13496,12 +13533,14 @@ const ExportProgressModal = ({ open, exportProgress, isExporting, onClose }) =>
<span className="text-[11px] text-slate-500 font-mono animate-pulse">Đang tải plugin VST — GUI embed sẽ hiện khi plugin sẵn sàng...</span>
</div>
)}
<img ref={imgRef} alt="VST GUI" className="w-full h-full object-contain select-none pointer-events-none" draggable={false} onLoad={() => { setFrameLoaded(true); frameLoadedRef.current = true; }} />
<img ref={imgRef} alt="VST GUI" className="w-full h-full object-contain select-none pointer-events-none" draggable={false} onLoad={() => setFrameLoaded(true)} />
</div>
</div>
);
};
const vstRow = (s, idx, toggleV, changeV, uploadV, moveV, removeV, fxl, chainLen, openGui, activeIdx = -1, onSelect = null) => (
<div key={idx} onClick={() => onSelect && onSelect(s, idx)} className={`flex items-center gap-2 bg-slate-900/60 border rounded-lg px-2 py-1.5 flex-wrap cursor-pointer transition-all ${activeIdx === idx ? 'border-teal-400 bg-slate-800/80' : 'border-slate-800 hover:border-slate-600'}`}>
<button onClick={e => { e.stopPropagation(); toggleV(idx); }} className={`w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0 ${!s.bypass ? 'bg-teal-500 text-slate-950' : 'bg-slate-700 text-slate-300'}`} title={s.bypass ? 'Bypass' : 'Active'}>
@@ -13767,6 +13806,29 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
raf = requestAnimationFrame(draw);
const w = canvas.width, h = canvas.height;
const ctx = canvas.getContext('2d');
const ana = window.__getTrackScopeAnalysers ? window.__getTrackScopeAnalysers(track.id) : null;
// V45.46 throttle: inactive (chưa play / paused) → vẽ grid+message ~mỗi 12
// frame — rAF vẫn chạy làm poll (play start không re-run effect vì deps
// thiếu isPlaying) nhưng skip các frame khác → hết repaint canvas 60fps.
if (!ana || scopePaused) {
st.frame = (st.frame || 0) + 1;
if (st.frame % 12 !== 1) return;
ctx.clearRect(0, 0, w, h);
// Grid
ctx.strokeStyle = 'rgba(51, 65, 85, 0.35)';
ctx.lineWidth = 1;
ctx.font = '9px monospace';
ctx.fillStyle = '#475569';
for (let i = 1; i < 6; i++) {
const y = (i / 6) * h;
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
}
ctx.fillText('+0.0 dB', 4, 12);
ctx.fillText('-6.0 dB', 4, h / 2 - 4);
ctx.fillText('-12 dB', 4, h - 6);
if (!ana) { ctx.fillStyle = '#334155'; ctx.font = '11px monospace'; ctx.fillText('Không có tín hiệu — bấm Play để xem waveform', w / 2 - 120, h / 2); }
return;
}
ctx.clearRect(0, 0, w, h);
// Grid
ctx.strokeStyle = 'rgba(51, 65, 85, 0.35)';
@@ -13780,11 +13842,6 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
ctx.fillText('+0.0 dB', 4, 12);
ctx.fillText('-6.0 dB', 4, h / 2 - 4);
ctx.fillText('-12 dB', 4, h - 6);
const ana = window.__getTrackScopeAnalysers ? window.__getTrackScopeAnalysers(track.id) : null;
if (!ana || scopePaused) {
if (!ana) { ctx.fillStyle = '#334155'; ctx.font = '11px monospace'; ctx.fillText('Không có tín hiệu — bấm Play để xem waveform', w / 2 - 120, h / 2); }
return;
}
const sr = ana.sr || 44100;
const maxS = Math.floor(5 * sr);
if (!st.L || st.L.length !== maxS) { st.L = new Float32Array(maxS); st.R = new Float32Array(maxS); st.head = 0; st.len = 0; }
@@ -13935,7 +13992,7 @@ const addVst = (path) => { const fx = (fxList || []).find(f => f.path === path);
}, [ap.g1, ap.g2, ap.g3, ap.g4, activeMod]);
return (
<div className="fixed inset-0 z-[115] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4" onClick={onClose}>
<div className="fixed inset-0 z-[115] bg-black/80 flex items-center justify-center p-4" onClick={onClose}>
<div className="w-full max-w-4xl bg-slate-900 border border-slate-700 rounded-2xl p-5 space-y-4 shadow-2xl flex flex-col max-h-[90vh] text-slate-200" onClick={e => e.stopPropagation()}>
{/* HEADER */}
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
@@ -14259,22 +14316,72 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
return () => clearInterval(iv);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [masterGuiOpen]);
// Modal đóng (modal luôn mounted, KHÔNG unmount): ẩn GUI + xoá persist để
// lần sau mở panel KHÔNG tự mở lại GUI (fix auto-open khi vào master chain).
// Chỉ POST /hide — bridge sống, click mở lại nhanh.
// Modal đóng (modal luôn mounted, KHÔNG unmount): ẩn GUI nhưng GIỮ marker
// gui_open_path/name + preset cuối → mở lại panel tự mở lại GUI đúng cài đặt.
// Chỉ POST /hide — bridge còn sống, mở lại nhanh. “Đóng GUI” (closeMasterGui)
// là thao tác chủ động xoá marker → không auto-mở lại lần sau.
React.useEffect(() => {
if (isOpen) return;
const g = masterGuiOpenRef.current;
if (!g) return;
try { if (g.path) { fetch(g.url + '/preset').then(r => r.json()).then(d => { if (d && d.preset_b64 && g.path) _capturedPresetByPath[g.path] = d.preset_b64; }).catch(() => {}); } } catch (e) {}
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];
setMasterGuiOpen(null);
setActiveMasterVstPath(null);
setOzState(prev => { const n = { ...(prev || {}) }; delete n.gui_open_path; delete n.gui_open_name; return n; });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
// P2.1 — Mở lại panel khi marker gui_open còn (chưa bấm “Đóng GUI”): tự mở lại
// GUI embed đúng plugin + preset cuối. Fail/spawn lỗi → GIỮ marker + toast lỗi
// thật + retry tối đa 4 lần — trước đây catch xoá marker vĩnh viễn + im lặng
// → GUI không bao giờ tự mở lại sau tắt/mở app (bug P2.1 reload). Chạy lại khi
// marker/chain xuất hiện sau (project restore chưa xong lúc mở panel).
const _autoIsOpenRef = React.useRef(isOpen);
_autoIsOpenRef.current = isOpen;
const _autoOzRef = React.useRef(ozState);
_autoOzRef.current = ozState;
const _autoOpenRef = React.useRef({ timer: null, attempt: 0, inFlight: false });
const _autoTryOpen = React.useCallback(() => {
const st = _autoOzRef.current || {};
const _p = st.gui_open_path;
if (!_p) return;
if (!_autoIsOpenRef.current || masterGuiOpenRef.current || _autoOpenRef.current.inFlight) return;
const s = (st.vstFxChain || []).find(x => x && x.path === _p);
if (!s || !window.SonicAPI || !window.SonicAPI.openFxGui) return;
_autoOpenRef.current.inFlight = true;
setActiveMasterVstPath(_p);
const _name = st.gui_open_name || s.name || _p;
window.SonicAPI.openFxGui({ path: _p, name: _name, embed: true, shm: fxRt.sessionId || '', preset_b64: _capturedPresetByPath[_p] || s.preset_b64 || '' }).then(r => {
_autoOpenRef.current.inFlight = false;
if (r && r.embed_url && !masterGuiOpenRef.current) { _autoOpenRef.current.attempt = 0; setMasterGuiOpen({ path: _p, url: r.embed_url, name: _name }); return; }
if (r && r.already_running && r.embed_url && !masterGuiOpenRef.current) { _autoOpenRef.current.attempt = 0; setMasterGuiOpen({ path: _p, url: r.embed_url, name: _name }); return; }
if (r && r.already_running) { _autoOpenRef.current.attempt = 0; setActiveMasterVstPath(null); window.showToast && window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)', 'info'); return; }
throw new Error((r && r.detail) || 'Bridge không mở được GUI embed');
}).catch(err => {
_autoOpenRef.current.inFlight = false;
console.warn('[MasterAutoOpen] openFxGui fail:', _p, (err && err.message) || err);
setActiveMasterVstPath(null);
if (_autoIsOpenRef.current && _autoOpenRef.current.attempt < 4 && !masterGuiOpenRef.current) {
_autoOpenRef.current.attempt += 1;
if (_autoOpenRef.current.timer) clearTimeout(_autoOpenRef.current.timer);
_autoOpenRef.current.timer = setTimeout(() => { _autoOpenRef.current.timer = null; _autoTryOpen(); }, 3000);
} else {
_autoOpenRef.current.attempt = 0;
window.showToast && window.showToast('Không tự mở được GUI VST: ' + ((err && err.message) || 'lỗi') + ' — bấm tên plugin trong chuỗi để mở tay.', 'error');
}
});
}, []);
React.useEffect(() => {
return () => { if (_autoOpenRef.current.timer) { clearTimeout(_autoOpenRef.current.timer); _autoOpenRef.current.timer = null; } };
}, []);
React.useEffect(() => {
if (!isOpen) { _autoOpenRef.current.attempt = 0; return; }
if (_autoOpenRef.current.timer) { clearTimeout(_autoOpenRef.current.timer); _autoOpenRef.current.timer = null; }
_autoTryOpen();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, ozState.gui_open_path, ozState.gui_open_name, ozState.vstFxChain]);
const setMasterVstChain = (next) => setOzState(prev => {
const chain = prev.chain || [];
const out = [];
@@ -14351,13 +14458,6 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
const previewAudioRef = React.useRef(null);
const [addMasterVstSel, setAddMasterVstSel] = React.useState('');
const [masterVstPickerOpen, setMasterVstPickerOpen] = React.useState(false);
// FX RT telemetry (WebView2 không mở được devtools → hiển thị thẳng trên UI).
const [fxRtStats, setFxRtStats] = React.useState(null);
React.useEffect(() => {
if (!isOpen) return;
const iv = setInterval(() => { if (fxRt && fxRt.stats) setFxRtStats({ ...fxRt.stats }); }, 2000);
return () => clearInterval(iv);
}, [isOpen]);
// Wave Observer Refs & States
const woCanvasRef = React.useRef(null);
@@ -14878,7 +14978,8 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
};
const removeChainModule = (modId) => {
const removedMod = (ozState.chain || []).find(m => m.id === modId);
if (removedMod && removedMod.type === 'vst3' && masterGuiOpen && masterGuiOpen.path === removedMod.path) closeMasterGui();
const wasGuiOpenFor = removedMod && removedMod.type === 'vst3' && masterGuiOpen && masterGuiOpen.path === removedMod.path;
if (wasGuiOpenFor) closeMasterGui();
setOzState(prev => {
const removed = prev.chain.find(m => m.id === modId);
const chain = prev.chain.filter(m => m.id !== modId);
@@ -14890,7 +14991,10 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
const vi = vstFxChain.findIndex(s => s.path === removed.path);
if (vi >= 0) vstFxChain = [...vstFxChain.slice(0, vi), ...vstFxChain.slice(vi + 1)];
}
return { ...prev, chain, ...flags, vstFxChain, activeModule: chain.length ? (chain.some(m => m.type === activeModule) ? activeModule : chain[chain.length - 1].type) : null };
const n = { ...prev, chain, ...flags, vstFxChain, activeModule: chain.length ? (chain.some(m => m.type === activeModule) ? activeModule : chain[chain.length - 1].type) : null };
// P2.1 — marker mồ côi khi xoá VST3 mà GUI không mở (panel đang đóng/ẩn)
if (removed && removed.type === 'vst3' && !wasGuiOpenFor && prev.gui_open_path === removed.path) { delete n.gui_open_path; delete n.gui_open_name; }
return n;
});
};
const reorderChain = (fromIdx, toIdx) => {
@@ -15001,7 +15105,7 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
);
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm" onClick={onClose}>
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70" onClick={onClose}>
<div id="masteringModalBody" className="flex flex-col bg-slate-950 text-slate-200 w-[95vw] h-[92vh] max-w-[1400px] rounded-2xl border border-slate-800/80 shadow-2xl overflow-hidden" onClick={e => e.stopPropagation()} style={{fontFamily: "'Inter', sans-serif"}}>
{/* TOP TRANSPORT & SESSION BAR */}
@@ -15092,17 +15196,6 @@ const addMasterVst = (path) => { const fx = (masterFxList || []).find(f => f.pat
</button>
</div>
{/* FX RT TELEMETRY — WebView2 không mở devtools, hiển thị thẳng trên UI */}
<div id="fxrt-stats-ui" className="h-6 bg-slate-950 border-b border-slate-800 px-4 flex items-center gap-3 text-[10px] oz-font-mono shrink-0">
<span className="text-slate-500 font-bold uppercase tracking-widest">FXRT</span>
{fxRtStats ? (
<span className={fxRtStats.underruns > 0 || fxRtStats.silences > 0 ? 'text-red-400' : 'text-emerald-400'}>
u={fxRtStats.underruns} h={fxRtStats.holds} f={fxRtStats.fades} s={fxRtStats.silences} q={fxRtStats.queueLen} cap={fxRtStats.capDrops} fadeIn={fxRtStats.fadeIns}
</span>
) : <span className="text-slate-600">off</span>}
{fxRtStats && (fxRtStats.underruns > 0 || fxRtStats.silences > 0) && <span className="text-red-400">⚠ main-thread stall? Giảm tải UI / tăng FILL</span>}
</div>
{/* MAIN WORKSPACE */}
<main className="flex-1 flex overflow-hidden min-h-0">
@@ -20187,7 +20280,27 @@ const App = () => {
// Đồ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) { fxRtStart(); }
if (fxRtChain().length) {
fxRtStart();
// V45.46: start từ settings-change lúc KHÔNG có playback (mount mở
// project có master chain / chỉnh PWR khi đang dừng) → engine từng chạy
// VÔ HẠN khi idle (fx_stats spam play=0 → CPU cao). Arm warm-idle
// teardown như stopAllPlayback(fade): play path (fxRtStart trong
// startTrackPlayback) và section play (startSubTabPlayback) clear timer
// ngay → chỉ teardown khi thực sự idle lâu. MIDI-live/rec giữ warm.
const _recNow = (activeAudioRecordersRef && activeAudioRecordersRef.current && Object.keys(activeAudioRecordersRef.current).length > 0) || (recordingStateRef && recordingStateRef.current === 'RECORDING');
let _liveMidi = false;
try { for (const _k in midiVuActivityRef.current) { if (midiVuActivityRef.current[_k] > 0.001) { _liveMidi = true; break; } } } catch (e) {}
const _demand = (isPlayingRef && isPlayingRef.current) || _recNow || _liveMidi ||
(subTabsRef && subTabsRef.current && subTabsRef.current.some(s => s && s.isPlaying));
if (!_demand) {
clearTimeout(fxRtWarmIdleTimer);
fxRtWarmIdleTimer = setTimeout(() => {
fxRtWarmIdleTimer = null;
try { fxRtStop(); } catch (e) { console.warn('[FX RT] idle stop error:', e); }
}, FX_RT_WARM_IDLE_MS);
}
}
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ị
@@ -20408,11 +20521,23 @@ const App = () => {
if (lastId.startsWith('local_')) {
var localData = localStorage.getItem('sonic_local_project_data');
if (localData) parsed = JSON.parse(localData);
} else if (lastId.startsWith('disk:')) {
// Ctrl-S desktop: mở lại file .sonicforge.json trong thư mục tài liệu
var diskFname = lastId.slice(5);
var diskProj = await window.SonicAPI.getDiskProject(diskFname);
if (diskProj && diskProj.data_json) parsed = JSON.parse(diskProj.data_json);
} else {
var proj = await window.SonicAPI.getCloudProject(lastId);
if (proj) parsed = JSON.parse(proj.data_json);
}
} catch (e) { console.warn('restore load error:', e); }
} catch (e) {
console.warn('restore load error:', e);
if (lastId && lastId.startsWith('disk:')) {
// File trên đĩa không còn (bị xoá) → bỏ id cũ, không lặp lại mỗi lần mở
localStorage.removeItem('sonic_project_id');
localStorage.removeItem('sonic_project_name');
}
}
}
try {
if (!parsed) return false;
@@ -29705,8 +29830,25 @@ const App = () => {
}
};
const handleSaveLocalProject = (name) => {
const handleSaveLocalProject = async (name) => {
const finalName = name || projectName || 'Dự án mới';
const runtime = (window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.runtime) || '';
if (runtime === 'desktop' && window.SonicAPI && window.SonicAPI.saveProjectToDisk) {
// Desktop: "Lưu Local" = lưu vào thư mục mặc định của máy
// (Documents/SonicForgeDAW/Projects) — sống sót khi cài lại. Id
// disk:<file> giúp lần khởi động sau tự mở lại dự án này.
const schemaObj = serializeProjectToSchema(currentProjectId || 'proj_' + Date.now(), finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume, { volL: masterVolL, volR: masterVolR, link: masterLink, muted: masterMuted });
const res = await window.SonicAPI.saveProjectToDisk(finalName, JSON.stringify(schemaObj));
const diskId = res && res.filename ? ('disk:' + res.filename) : null;
if (diskId) {
setCurrentProjectId(diskId);
localStorage.setItem('sonic_project_id', diskId);
}
setProjectName(finalName);
localStorage.setItem('sonic_project_name', finalName);
showToast(`Đã lưu dự án "${finalName}" vào thư mục tài liệu.`, "success");
return;
}
const localId = currentProjectId && currentProjectId.startsWith('local_') ? currentProjectId : 'local_' + Date.now();
const projectSchemaObj = serializeProjectToSchema(localId, finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume, { volL: masterVolL, volR: masterVolR, link: masterLink, muted: masterMuted });
const dataStr = JSON.stringify(projectSchemaObj);
@@ -29778,7 +29920,7 @@ const App = () => {
setSaveProjectModalOpen(true);
return;
}
if (currentProjectId.startsWith('local_')) {
if (currentProjectId && (currentProjectId.startsWith('local_') || currentProjectId.startsWith('disk:'))) {
handleSaveLocalProject(projectName);
} else {
if (!currentUser) { showToast('Cần đăng nhập để lưu Cloud. currentUser=' + (currentUser ? 'OK' : 'NULL') + ' token=' + (localStorage.getItem('sonic_token') ? 'exists' : 'missing'), 'warning'); return; }
@@ -29798,8 +29940,16 @@ const App = () => {
const dataJson = JSON.stringify(schemaObj);
const runtime = (window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.runtime) || '';
if (runtime === 'desktop' && window.SonicAPI && window.SonicAPI.saveProjectToDisk) {
// Desktop → thư mục của hệ điều hành
// Desktop → thư mục của hệ điều hành (Documents/SonicForgeDAW/Projects)
const res = await window.SonicAPI.saveProjectToDisk(finalName, dataJson);
// Đăng ký id disk:<file> → lần khởi động sau tự mở lại dự án này
const diskId = res && res.filename ? ('disk:' + res.filename) : null;
if (diskId) {
setCurrentProjectId(diskId);
setProjectName(finalName);
localStorage.setItem('sonic_project_id', diskId);
localStorage.setItem('sonic_project_name', finalName);
}
showToast('Đã lưu dự án: ' + ((res && res.path) || finalName), 'success');
} else if (window.SonicAPI) {
// Docker / headless → Cloud (nếu đã đăng nhập), ngược lại local
@@ -32330,6 +32480,7 @@ STRICT CONSTRAINTS:
// Master & Track VU meter animation loop
const masterVUAnimRef = useRef(null);
useEffect(() => {
let lastIdleDraw = 0;
function tick() {
try {
// Khi STOP (không play, không recording): vẽ VU RỖNG — không đọc analyser/
@@ -32346,14 +32497,22 @@ STRICT CONSTRAINTS:
}
} catch (e) { }
if (!isPlaying && !isRecActive && !liveMidiVu) {
setMasterVU(0);
setMasterMeterPeak(0);
Object.keys(trackVuRefs.current).forEach(k => {
const c = trackVuRefs.current[k];
if (!c) return;
if (k.endsWith('_mixer')) drawMixerVuMeter(c, 0);
else drawVuMeter(c, -60);
});
// V45.46 throttle: vẽ VU rỗng ~10fps (throttle 100ms) — trước đây 60fps
// mỗi frame setState + vẽ canvas → GPU process CPU cao khi STOP. Vòng rAF
// vẫn chạy (skip-frame đóng vai poll: record/liveMidi không re-run effect
// vì deps chỉ [isPlaying]) nhưng không repaint các frame thừa.
const _nowIdle = performance.now();
if (_nowIdle - lastIdleDraw >= 100) {
lastIdleDraw = _nowIdle;
setMasterVU(0);
setMasterMeterPeak(0);
Object.keys(trackVuRefs.current).forEach(k => {
const c = trackVuRefs.current[k];
if (!c) return;
if (k.endsWith('_mixer')) drawMixerVuMeter(c, 0);
else drawVuMeter(c, -60);
});
}
masterVUAnimRef.current = requestAnimationFrame(tick);
return;
}
@@ -36012,6 +36171,19 @@ STRICT CONSTRAINTS:
onOpenLocal: () => {
setOpenProjectModalOpen(false);
handleImportSFS();
},
onOpenDisk: (filename, name) => {
setOpenProjectModalOpen(false);
setAppWarningModal({
title: "Mở dự án từ máy",
message: 'Bạn có muốn mở dự án "' + (name || filename) + '" lưu trên máy? Các thay đổi chưa lưu sẽ bị mất.',
isAlert: false,
onConfirm: async () => {
localStorage.setItem('sonic_project_id', 'disk:' + filename);
localStorage.setItem('sonic_project_name', name || filename);
window.location.reload();
}
});
}
}), /*#__PURE__*/React.createElement(AIConfigModal, {
isOpen: aiConfigModalOpen,
File diff suppressed because one or more lines are too long
+2
View File
@@ -130,6 +130,8 @@ window.API_BASE_URL = (window.API_BASE_URL || window.location.origin).replace(/\
// Ctrl-S desktop: lưu project ra THƯ MỤC CỦA HỆ ĐIỀU HÀNH
// (Documents/SonicForgeDAW/Projects — Windows; ~/SonicForgeDAW/Projects — Linux)
saveProjectToDisk: (name, dataJson) => apiRequest('/api/v1/projects/save-to-disk', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
listDiskProjects: () => apiRequest('/api/v1/projects/disk', { method: 'GET' }),
getDiskProject: (filename) => apiRequest('/api/v1/projects/disk/' + encodeURIComponent(filename), { method: 'GET' }),
deleteSoundFont: (sfId) => apiRequest(`/api/v1/plugins/soundfont/${sfId}`, { method: 'DELETE' }),
uploadSoundFont: async (file) => {
const formData = new FormData();