Fix master volume + VST3FX GUI state restore on save/reload

- serializeProjectToSchema now writes master volume_db and mastering gui_open_path/name
- deserialize restores masterVolume + masteringSettings.gui_open_path/name
- track vst_gui_open_path/name fallback to live state (not just _persistFxGui*)
- MasteringModal/FXRackModal reopen effects react to isOpen + restored gui path
- openMasterVstGui/openVstGui store gui name for reopen
- bridge: /preset capture endpoint + RenderFxJob.captureState for GUI knob state
This commit is contained in:
2026-08-19 14:44:40 +07:00
parent 8874e42596
commit 2f5c70ac29
6 changed files with 308 additions and 102 deletions
+138 -61
View File
@@ -11029,8 +11029,10 @@ const serializeTracksList = (tracksList, secondsPerBar) => {
synth_engine: t.synth_engine || undefined,
midi_channel: t.midiChannel !== undefined ? t.midiChannel : null,
fx_chain: (t.fxChain || []).map(m => typeof m === 'string' ? { type: m, active: true } : { type: m.type, active: m.active !== false }),
vst_fx_chain: (t.vstFxChain || []).map(s => ({ type: s.type || 'vst3', path: s.path || '', name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass })),
vst_fx_chain: (t.vstFxChain || []).map(s => ({ type: s.type || 'vst3', path: s.path || '', name: s.name || '', preset_b64: _capturedPresetByPath[s.path] || s.preset_b64 || '', bypass: !!s.bypass })),
vst_fx_bypass: !!t.vstFxBypass,
vst_gui_open_path: (t.id === _persistFxGuiTrackId && _persistFxGuiOpen && _persistFxGuiOpen.path) ? _persistFxGuiOpen.path : (t.vstGuiOpenPath || null),
vst_gui_open_name: (t.id === _persistFxGuiTrackId && _persistFxGuiOpen && _persistFxGuiOpen.name) ? _persistFxGuiOpen.name : (t.vstGuiOpenName || null),
server_file_id: t.serverFileId || null,
items: items
};
@@ -11133,11 +11135,13 @@ const deserializeTracksList = (schemaTracks, secondsPerBar, sectionStore, visite
fxChain: (t.fx_chain || []).map(m => typeof m === 'string' ? { type: m, active: true } : { type: m.type || m, active: m.active !== false }),
vstFxChain: (t.vst_fx_chain || []).map(s => ({ type: s.type || 'vst3', path: s.path || '', name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass })),
vstFxBypass: t.vst_fx_bypass !== undefined ? !!t.vst_fx_bypass : false,
vstGuiOpenPath: t.vst_gui_open_path || null,
vstGuiOpenName: t.vst_gui_open_name || null,
};
});
};
const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsList, sessionTabsList, masteringSettings) => {
const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsList, sessionTabsList, masteringSettings, masterVolume) => {
const secondsPerBar = (60.0 / parseFloat(bpmVal || 120)) * 4;
const mainTracks = serializeTracksList(tracksList, secondsPerBar);
@@ -11244,10 +11248,10 @@ const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsLi
})(),
auto_compute_length: true,
tracks: mainTracks,
...(masteringSettings && Array.isArray(masteringSettings.vstFxChain) && masteringSettings.vstFxChain.length > 0 ? {
...((masteringSettings && Array.isArray(masteringSettings.vstFxChain) && masteringSettings.vstFxChain.length > 0) || (typeof masterVolume === 'number' && isFinite(masterVolume) && masterVolume !== 0) ? {
master: {
fx_chain: masteringSettings.vstFxChain.map(s => ({ type: s.type || 'vst3', path: s.path || '', name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass })),
volume_db: 0,
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
}
@@ -11255,11 +11259,17 @@ const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsLi
},
sub_tabs: subTabs,
section_store: sectionStore,
mastering_settings: masteringSettings || null
mastering_settings: masteringSettings ? {
...masteringSettings,
gui_open_path: (_persistMasterGuiOpen && _persistMasterGuiOpen.path) ? _persistMasterGuiOpen.path : (masteringSettings && masteringSettings.gui_open_path) || null,
gui_open_name: (_persistMasterGuiOpen && _persistMasterGuiOpen.name) ? _persistMasterGuiOpen.name : (masteringSettings && masteringSettings.gui_open_name) || null
} : null
};
};
const deserializeProjectFromSchema = (schemaObj) => {
// GUI knob captures are per-project - don't leak presets between projects.
_capturedPresetByPath = {};
const bpmVal = schemaObj.metadata ? schemaObj.metadata.bpm : 120;
const secondsPerBar = (60.0 / bpmVal) * 4;
@@ -11346,8 +11356,11 @@ const deserializeProjectFromSchema = (schemaObj) => {
masteringSettings: {
..._migratedMastering,
vstFxChain: _masterFx.map(s => ({ type: s.type || 'vst3', path: s.path || '', name: s.name || '', preset_b64: s.preset_b64 || '', bypass: !!s.bypass })),
vstBypass: !!_masterBus.bypass
vstBypass: !!_masterBus.bypass,
gui_open_path: (schemaObj.mastering_settings && schemaObj.mastering_settings.gui_open_path) ? schemaObj.mastering_settings.gui_open_path : null,
gui_open_name: (schemaObj.mastering_settings && schemaObj.mastering_settings.gui_open_name) ? schemaObj.mastering_settings.gui_open_name : null
},
masterVolume: (typeof _masterBus.volume_db === 'number' && isFinite(_masterBus.volume_db)) ? _masterBus.volume_db : 0,
zoom: schemaObj.metadata && schemaObj.metadata.zoom ? parseFloat(schemaObj.metadata.zoom) : 1.0
};
};
@@ -12077,6 +12090,11 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor
// GUI embed state survives FXRackModal unmount so the native window
// re-shows automatically when the rack is reopened (close/reopen bug).
let _persistFxGuiOpen = null;
// Track id whose rack owns the currently-open embedded GUI (for save).
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 = {};
const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
const [activeType, setActiveType] = React.useState('eq');
const [addModuleOpen, setAddModuleOpen] = React.useState(false);
@@ -12113,21 +12131,41 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
const setVstChain = (next) => { if (onUpdateTrack) onUpdateTrack(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; setFxGuiOpenState(v); };
const setFxGuiOpen = (v) => { _persistFxGuiOpen = v; _persistFxGuiTrackId = v ? (track && track.id) : null; setFxGuiOpenState(v); };
const fxGuiOpenRef = React.useRef(null);
fxGuiOpenRef.current = fxGuiOpen;
const closeFxGui = () => {
const g = fxGuiOpenRef.current;
if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} }
setFxGuiOpen(null);
if (onUpdateTrack && track) onUpdateTrack(track.id, { vstGuiOpenPath: null, vstGuiOpenName: null });
};
React.useEffect(() => () => { const g = fxGuiOpenRef.current; if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} } }, []);
// Capture GUI knob tweaks while the embedded GUI is open (bridge /preset).
React.useEffect(() => {
if (!fxGuiOpen || !fxGuiOpen.url) return;
const iv = setInterval(() => {
fetch(fxGuiOpen.url + '/preset').then(r => r.json()).then(d => { if (d && d.preset_b64) _capturedPresetByPath[fxGuiOpen.path] = d.preset_b64; }).catch(() => {});
}, 1500);
return () => clearInterval(iv);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fxGuiOpen]);
// Reopen the saved GUI when the rack is mounted after a project restore.
React.useEffect(() => {
if (!track || !track.vstGuiOpenPath || fxGuiOpen) return;
const s = (track.vstFxChain || []).find(x => x.path === track.vstGuiOpenPath);
if (!s || !window.SonicAPI || !window.SonicAPI.openFxGui) return;
window.SonicAPI.openFxGui({ path: s.path, name: s.name || s.path, embed: true }).then(r => {
if (r && r.embed_url) setFxGuiOpen({ path: s.path, url: r.embed_url });
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [track && track.id, track && track.vstGuiOpenPath]);
const toggleVst = (idx) => setVstChain(vstChain.map((s, i) => i === idx ? { ...s, bypass: !s.bypass } : s));
const removeVst = (idx) => { const rem = vstChain[idx]; if (rem && fxGuiOpen && fxGuiOpen.path === rem.path) closeFxGui(); if (idx === activeVstIdx) setActiveVstIdx(-1); setVstChain(vstChain.filter((_, i) => i !== idx)); };
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 }).then(r => { if (r && r.embed_url) setFxGuiOpen({ path: s.path, url: r.embed_url }); 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 }).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) 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); setVstChain(vstChain.map((s, i) => i === idx ? { ...s, path, name: fx ? (fx.name || fx.id) : s.name, preset_b64: '' } : s)); };
const changeVstPlugin = (idx, path) => { const fx = (fxList || []).find(f => f.path === path); delete _capturedPresetByPath[path]; setVstChain(vstChain.map((s, i) => i === idx ? { ...s, path, name: fx ? (fx.name || fx.id) : s.name, preset_b64: '' } : s)); };
const uploadVstPreset = (idx) => {
const inp = document.createElement('input');
inp.type = 'file';
@@ -12665,8 +12703,28 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} }
setMasterGuiOpen(null);
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) {} } }, []);
// 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(() => {});
}, 1500);
return () => clearInterval(iv);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [masterGuiOpen]);
// Reopen the saved GUI when the panel is opened after a project restore.
React.useEffect(() => {
if (!isOpen || !ozState || !ozState.gui_open_path || masterGuiOpen) return;
const s = (ozState.vstFxChain || []).find(x => x.path === ozState.gui_open_path);
if (!s || !window.SonicAPI || !window.SonicAPI.openFxGui) return;
window.SonicAPI.openFxGui({ path: s.path, name: ozState.gui_open_name || s.name || s.path, embed: true }).then(r => {
if (r && r.embed_url) setMasterGuiOpen({ path: s.path, url: r.embed_url });
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, ozState && ozState.gui_open_path]);
const setMasterVstChain = (next) => setOzState(prev => ({ ...prev, vstFxChain: next }));
// T2.4: push the master VST FX chain to the realtime bridge (live master
// mix). v1: master chain only — per-track vstChain stays offline (defer).
@@ -12676,12 +12734,12 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
window.NativeBridgeService.setFxChain(JSON.stringify(chain));
}, [ozState.vstFxChain]);
const toggleMasterVst = (idx) => setMasterVstChain(masterVstChain.map((s, i) => i === idx ? { ...s, bypass: !s.bypass } : s));
const removeMasterVst = (idx) => setMasterVstChain(masterVstChain.filter((_, i) => i !== idx));
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 }).then(r => { if (r && r.embed_url) setMasterGuiOpen({ path: s.path, url: r.embed_url }); 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 }).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) 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); setMasterVstChain(masterVstChain.map((s, i) => i === idx ? { ...s, path, name: fx ? (fx.name || fx.id) : s.name, preset_b64: '' } : s)); };
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)); };
const uploadMasterVstPreset = (idx) => {
const inp = document.createElement('input');
inp.type = 'file';
@@ -17946,7 +18004,7 @@ const App = () => {
// Render offline toàn project qua native_bridge (VST FX masterbus + track) → WAV.
// Dùng cho nút "Nghe thử" trong FX Rack / Mastering — preview = âm export.
window.__renderProjectPreview = async () => {
const schema = serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs, masteringSettings);
const schema = serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume);
try {
const res = await window.SonicAPI.renderProject(schema, 'preview_' + Date.now() + '.wav');
return res;
@@ -18169,7 +18227,7 @@ const App = () => {
const restoreLastSessionProject = async () => {
var pendingWasNull = !window.__pendingSfsProject;
loadPendingSfsProject();
if (!pendingWasNull) return;
if (!pendingWasNull) return true; // da xu li .sfs pending - khong de bang temp
var lastId = localStorage.getItem('sonic_project_id');
var lastName = localStorage.getItem('sonic_project_name') || 'Dự án';
@@ -18203,7 +18261,7 @@ const App = () => {
} catch (e) { console.warn('restore load error:', e); }
}
try {
if (!parsed) return;
if (!parsed) return false;
var restoredBpm = bpm;
var restoredTracks = [];
var restoredSessionTabs = [];
@@ -18218,6 +18276,7 @@ const App = () => {
// Explorer) — áp dụng mới giữ zoom hiện tại/localStorage — hết thu nhỏ.
if (result.zoom > 1 && setZoom) setZoom(result.zoom);
if (result.masteringSettings) setMasteringSettings(result.masteringSettings);
if (result.masterVolume !== undefined && result.masterVolume !== null) setMasterVolume(result.masterVolume);
} else {
restoredTracks = (parsed.tracks || []).map(function(t) {
var rest = Object.assign({}, t); delete rest.height;
@@ -18255,15 +18314,49 @@ const App = () => {
if (rt.sections) restoredItemCount += rt.sections.length;
});
showToast('Đã khôi phục dự án "' + lastName + '" (' + restoredItemCount + ' items).', 'info');
return true;
} catch(e) {
console.warn('restoreLastSessionProject failed:', e);
// Stale session id (project deleted / DB reset): clear it so the error
// does not repeat on every page load.
localStorage.removeItem('sonic_project_id');
localStorage.removeItem('sonic_project_name');
return false;
}
};
// Khoi phuc du an dang lam do tu temp autosave, CHI khi chua co project nao
// duoc restore (restoreLastSessionProject tra false). Goi o ca luong login
// modal (handleAuthSuccess) lan luong auto-login (checkAuthStatus).
const restoreTempProjectIfFresh = async () => {
try {
const tmp = await window.SonicAPI.getTempProject();
if (tmp && tmp.has_temp && tmp.data_json) {
let proj = tmp.data_json;
if (typeof proj === 'string') { try { proj = JSON.parse(proj); } catch (e) { proj = null; } }
if (proj && proj.main_session) {
const freshStart = !(tracks && tracks.some(t => (t.clips && t.clips.length > 0) || (t.midiItems && t.midiItems.length > 0) || t.buffer));
if (freshStart) {
const result = deserializeProjectFromSchema(proj);
if (result && result.tracks && result.tracks.length > 0) {
setTracks(result.tracks);
loadAudioBuffersForTracks(result.tracks);
setBpm((result.bpm || 120).toString());
if (result.sessionTabs && result.sessionTabs.length > 0) setSessionTabs(result.sessionTabs);
if (result.subTabs && result.subTabs.length > 0) setSubTabs(result.subTabs);
if (result.masteringSettings) setMasteringSettings(result.masteringSettings);
if (result.masterVolume !== undefined && result.masterVolume !== null) setMasterVolume(result.masterVolume);
const tmpName = (proj.metadata && proj.metadata.title) || 'Dự án tạm';
setProjectName(tmpName);
localStorage.setItem('sonic_project_name', tmpName);
showToast('Đã khôi phục dự án đang làm dở từ bản lưu tạm', 'success');
}
}
}
}
} catch (e) { console.warn('[TempRestore] error:', e); }
};
useEffect(() => {
const checkAuthStatus = async () => {
const savedToken = localStorage.getItem('sonic_token');
@@ -18284,7 +18377,10 @@ const App = () => {
} else {
setIsMandatoryLogin(false);
setAuthModalOpen(false);
restoreLastSessionProject();
(async () => {
const restored = await restoreLastSessionProject();
if (!restored) await restoreTempProjectIfFresh();
})();
}
} catch (err) {
const cached = localStorage.getItem('sonic_user');
@@ -18292,7 +18388,10 @@ const App = () => {
try { setCurrentUser(JSON.parse(cached)); } catch (_) { }
setIsMandatoryLogin(false);
setAuthModalOpen(false);
restoreLastSessionProject();
(async () => {
const restored = await restoreLastSessionProject();
if (!restored) await restoreTempProjectIfFresh();
})();
} else {
localStorage.removeItem('sonic_token');
localStorage.removeItem('sonic_user');
@@ -18352,6 +18451,7 @@ const App = () => {
carlaBridgeActive: false, carlaBridge: { plugin: '', plugin_path: '', connected: false },
});
}
if (result.masterVolume !== undefined && result.masterVolume !== null) setMasterVolume(result.masterVolume);
} else {
restoredTracks = (proj.tracks || []).map(t => {
const { height: _h, ...rest } = t;
@@ -18391,7 +18491,10 @@ const App = () => {
} else {
setIsMandatoryLogin(false);
setAuthModalOpen(false);
restoreLastSessionProject();
(async () => {
const restored = await restoreLastSessionProject();
if (!restored) await restoreTempProjectIfFresh();
})();
const loadPrefs = (p) => {
if (!p) return;
if (p.showAIPanel !== undefined) setShowAIPanel(p.showAIPanel);
@@ -18441,35 +18544,6 @@ const App = () => {
setInstrumentSelectorData(data);
}).catch(() => {});
} catch (e) { }
// ── Khôi phục dự án đang làm dở (temp autosave) khi load lại app ──
// Yêu cầu: "Khi tắt ứng dụng → tự động lưu temp; khi load lại → tải lại
// dự án đang làm dở." Chỉ restore khi app khởi động với project TRỐNG
// (chưa có clip/midi/audio nào) — không đè lên dự án user đang mở.
try {
const tmp = await window.SonicAPI.getTempProject();
if (tmp && tmp.has_temp && tmp.data_json) {
let proj = tmp.data_json;
if (typeof proj === 'string') { try { proj = JSON.parse(proj); } catch (e) { proj = null; } }
if (proj && proj.main_session) {
const freshStart = !(tracks && tracks.some(t => (t.clips && t.clips.length > 0) || (t.midiItems && t.midiItems.length > 0) || t.buffer));
if (freshStart) {
const result = deserializeProjectFromSchema(proj);
if (result && result.tracks && result.tracks.length > 0) {
setTracks(result.tracks);
loadAudioBuffersForTracks(result.tracks);
setBpm((result.bpm || 120).toString());
if (result.sessionTabs && result.sessionTabs.length > 0) setSessionTabs(result.sessionTabs);
if (result.subTabs && result.subTabs.length > 0) setSubTabs(result.subTabs);
if (result.masteringSettings) setMasteringSettings(result.masteringSettings);
const tmpName = (proj.metadata && proj.metadata.title) || 'Dự án tạm';
setProjectName(tmpName);
localStorage.setItem('sonic_project_name', tmpName);
showToast('Đã khôi phục dự án đang làm dở từ bản lưu tạm', 'success');
}
}
}
}
} catch (e) { console.warn('[TempRestore] error:', e); }
})();
}
};
@@ -18503,9 +18577,9 @@ const App = () => {
} : null
}));
window.SonicStorage.scheduleTempAutoSave(() => {
return serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs, masteringSettings);
return serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume);
});
}, [tracks, subTabs, sessionTabs, masteringSettings]);
}, [tracks, subTabs, sessionTabs, masteringSettings, masterVolume]);
// ── Temp autosave SERVER khi tắt ứng dụng (Bug: đóng app mất dự án làm dở) ──
// Lưu temp vào thư mục temp của ứng dụng (storage/temp + DB) khi:
@@ -18515,7 +18589,7 @@ const App = () => {
useEffect(() => {
const buildTempPayload = () => {
try {
const schemaObj = serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs, masteringSettings);
const schemaObj = serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume);
const cid = (window.SonicStorage && window.SonicStorage.getClientId) ? window.SonicStorage.getClientId() : '';
return JSON.stringify({ data_json: JSON.stringify(schemaObj), client_id: cid });
} catch (e) { return null; }
@@ -18551,7 +18625,7 @@ const App = () => {
window.removeEventListener('pagehide', onUnload);
clearInterval(interval);
};
}, [currentProjectId, projectName, bpm, tracks, subTabs, sessionTabs, masteringSettings]);
}, [currentProjectId, projectName, bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume]);
// ── Realtime sync giữa các client (LAN browser ↔ Tauri standalone) ──
// Bug 3: 2 session cùng tài khoản không sync. Poll revision nhẹ 3s; khi
@@ -18592,6 +18666,7 @@ const App = () => {
// (modal đang chỉnh) — bản đồng bộ từ thiết bị khác
// không được revert chain đang sửa (ví dụ vừa xóa VST).
if (result.masteringSettings && !masteringModalOpenRef.current) setMasteringSettings(result.masteringSettings);
if (result.masterVolume !== undefined && result.masterVolume !== null) setMasterVolume(result.masterVolume);
const tmpName = (proj.metadata && proj.metadata.title) || 'Dự án tạm';
setProjectName(tmpName);
localStorage.setItem('sonic_project_name', tmpName);
@@ -18622,14 +18697,14 @@ const App = () => {
if (currentProjectId.startsWith('local_')) {
const finalName = projectName || 'Dự án mới';
const localId = currentProjectId;
const schemaObj = serializeProjectToSchema(localId, finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings);
const schemaObj = serializeProjectToSchema(localId, finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume);
const dataStr = JSON.stringify(schemaObj);
localStorage.setItem('sonic_local_project_data', dataStr);
localStorage.setItem('sonic_project_id', localId);
localStorage.setItem('sonic_project_name', finalName);
} else if (currentUser && window.SonicAPI) {
try {
const schemaObj = serializeProjectToSchema(currentProjectId, projectName, bpm, tracks, subTabs, sessionTabs, masteringSettings);
const schemaObj = serializeProjectToSchema(currentProjectId, projectName, bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume);
const dataJson = JSON.stringify(schemaObj);
window.SonicAPI.updateCloudProject(currentProjectId, projectName || 'Dự án mới', dataJson).catch(() => {});
} catch (e) {
@@ -25543,7 +25618,7 @@ const App = () => {
try {
if (window.SonicStorage) {
const nextTracks = (tracks || []).map(t => t.id === trackId ? { ...t, color } : t);
window.SonicStorage.scheduleTempAutoSave(() => serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, nextTracks, subTabs, sessionTabs, masteringSettings));
window.SonicStorage.scheduleTempAutoSave(() => serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, nextTracks, subTabs, sessionTabs, masteringSettings, masterVolume));
if (window.SonicStorage.flushTempAutoSave) window.SonicStorage.flushTempAutoSave();
}
} catch (e) {}
@@ -26396,7 +26471,7 @@ const App = () => {
const handleSaveLocalProject = (name) => {
const finalName = name || projectName || 'Dự án mới';
const localId = currentProjectId && currentProjectId.startsWith('local_') ? currentProjectId : 'local_' + Date.now();
const projectSchemaObj = serializeProjectToSchema(localId, finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings);
const projectSchemaObj = serializeProjectToSchema(localId, finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume);
const dataStr = JSON.stringify(projectSchemaObj);
localStorage.setItem('sonic_local_project_data', dataStr);
localStorage.setItem('sonic_project_id', localId);
@@ -26419,7 +26494,7 @@ const App = () => {
const handleSaveCloudProject = async (name, existingProjectId) => {
const finalName = name || projectName || 'Dự án mới';
var useProjectId = existingProjectId || currentProjectId;
const projectSchemaObj = serializeProjectToSchema(useProjectId || 'project_' + Date.now(), finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings);
const projectSchemaObj = serializeProjectToSchema(useProjectId || 'project_' + Date.now(), finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume);
// Diagnostic: count source vs serialized items
var srcMidi = 0, srcClip = 0, srcSec = 0;
tracks.forEach(function(t) { srcMidi += (t.midiItems||[]).length; srcClip += (t.clips||[]).length; srcSec += (t.sections||[]).length; });
@@ -26482,7 +26557,7 @@ const App = () => {
const handleGlobalSave = async () => {
try {
const finalName = projectName || 'Dự án mới';
const schemaObj = serializeProjectToSchema(currentProjectId || 'proj_' + Date.now(), finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings);
const schemaObj = serializeProjectToSchema(currentProjectId || 'proj_' + Date.now(), finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume);
const dataJson = JSON.stringify(schemaObj);
const runtime = (window.SonicRuntime && window.SonicRuntime.capabilities && window.SonicRuntime.capabilities.runtime) || '';
if (runtime === 'desktop' && window.SonicAPI && window.SonicAPI.saveProjectToDisk) {
@@ -26520,7 +26595,7 @@ const App = () => {
}, []);
const handleSaveAsCloud = async (newName) => {
const projectSchemaObj = serializeProjectToSchema('project_' + Date.now(), newName, bpm, tracks, subTabs, sessionTabs, masteringSettings);
const projectSchemaObj = serializeProjectToSchema('project_' + Date.now(), newName, bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume);
const dataJson = JSON.stringify(projectSchemaObj);
try {
const res = await window.SonicAPI.saveCloudProject(newName, dataJson);
@@ -26540,7 +26615,7 @@ const App = () => {
const handleSaveCloud = handleSaveProject;
const handleExportSFS = (customName = null) => {
const finalName = customName || projectName || 'Dự án SonicForge';
const projectSchemaObj = serializeProjectToSchema(currentProjectId || 'proj_' + Date.now(), finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings);
const projectSchemaObj = serializeProjectToSchema(currentProjectId || 'proj_' + Date.now(), finalName, bpm, tracks, subTabs, sessionTabs, masteringSettings, masterVolume);
window.SonicStorage.exportProjectToSFS(projectSchemaObj);
showToast("Đã xuất dự án (.sfs) thành công!", "success");
};
@@ -26563,6 +26638,7 @@ const App = () => {
restoredSessionTabs = result.sessionTabs;
restoredSubTabs = result.subTabs;
if (result.masteringSettings) setMasteringSettings(result.masteringSettings);
if (result.masterVolume !== undefined && result.masterVolume !== null) setMasterVolume(result.masterVolume);
} else {
restored = (proj.tracks || []).map(t => ({
...t,
@@ -29052,6 +29128,7 @@ STRICT CONSTRAINTS:
// Explorer) — áp dụng mới giữ zoom hiện tại/localStorage.
if (result.zoom > 1 && setZoom) setZoom(result.zoom);
if (result.masteringSettings) setMasteringSettings(result.masteringSettings);
if (result.masterVolume !== undefined && result.masterVolume !== null) setMasterVolume(result.masterVolume);
} else {
restoredTracks = (parsed.tracks || []).map(function (t) {
const rest = Object.assign({}, t); delete rest.height;
File diff suppressed because one or more lines are too long
Binary file not shown.
+6
View File
@@ -55,6 +55,12 @@ int run_open_fx_gui(const std::string& jobPath);
// --open-fx-gui.
int run_fx_gui_server(const std::string& jobPath);
// Capture the GUI plugin's current state (component + controller) as base64
// of the bridge blob [4B BE compLen][comp][4B BE ctrlLen][ctrl] - the same
// format applyPreset accepts. Runs on the GUI pump thread; blocks up to
// timeoutMs. outB64 stays empty on timeout or when the plugin has no state.
void fxGuiRequestCapture(void* hwnd, std::string& outB64, int timeoutMs);
// Realtime FX chain (T2.4): live VST3 audio effects on the bridge master mix.
// setChain() is async (a worker thread loads the plugins and swaps the chain);
// process() runs ONLY on the audio thread; shutdown() joins the worker and must
+7
View File
@@ -11,6 +11,7 @@
// POST /input -> {type:"mousedown|mousemove|mouseup|wheel|keydown|keyup",
// x,y,button,deltaY,keyCode} -> PostMessage to HWND
// POST /close -> detach + exit 0
// GET /preset -> {"preset_b64":"..."} current plugin state
//
// The HTTP server runs on a worker thread; the calling (STA) thread runs the
// message pump — VST3 editors need COM apartment + pump on the same thread.
@@ -44,6 +45,7 @@
// Defined in RenderFxJob.cpp (global scope); declared here outside the
// anonymous namespace so both TUs see the same symbol.
extern void fxGuiRequestResize(HWND hwnd, int w, int h);
extern void fxGuiRequestCapture(void* hwnd, std::string& outB64, int timeoutMs);
namespace {
@@ -575,6 +577,11 @@ void runHttpServer(HWND hwnd, std::atomic<bool>& stop) {
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW);
}
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
} else if (req.path == "/preset") {
std::string b64;
fxGuiRequestCapture(hwnd, b64, 1500);
const std::string body = "{\"preset_b64\":\"" + b64 + "\"}";
sendResponse(c, 200, "application/json", body.data(), (int)body.size());
} else if (req.path == "/close") {
sendResponse(c, 200, "application/json", "{\"ok\":true}", 11);
stop.store(true);
+104
View File
@@ -44,6 +44,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <iostream>
@@ -258,6 +259,24 @@ static std::string base64Encode(const uint8_t* data, size_t len) {
}
return out;
}
// Append-only variant (no return temporary) so it can run inside an SEH
// __try frame (C2712: __try functions cannot have destructor temporaries).
static void base64EncodeTo(const uint8_t* data, size_t len, std::string& out) {
static const char* tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
size_t cap = ((len + 2) / 3) * 4;
out.clear();
out.reserve(cap);
for (size_t i = 0; i < len; i += 3) {
uint32_t n = (uint32_t)data[i] << 16;
if (i + 1 < len) n |= (uint32_t)data[i + 1] << 8;
if (i + 2 < len) n |= (uint32_t)data[i + 2];
out += tbl[(n >> 18) & 63];
out += tbl[(n >> 12) & 63];
out += (i + 1 < len) ? tbl[(n >> 6) & 63] : '=';
out += (i + 2 < len) ? tbl[n & 63] : '=';
}
}
static int b64val(unsigned char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
@@ -619,6 +638,43 @@ public:
std::cout << "[RenderFx] preset applied component=" << comp.size()
<< " controller=" << ctrl.size() << std::endl;
return true;
#endif
}
// Capture the plugin's CURRENT state (component + edit controller) as the
// bridge blob [4B BE compLen][comp][4B BE ctrlLen][ctrl] base64 — same
// format applyPreset accepts. Lets the GUI bridge report knob tweaks so a
// saved project can restore them. Must run on the thread owning the plugin.
void captureState(std::string& out) {
#ifndef HAVE_VST3SDK
(void)out;
return;
#else
auto* s = static_cast<Vst3FxState*>(state_);
if (!s) return;
std::vector<uint8_t> comp, ctrl;
{
Steinberg::MemoryStream stream;
if (s->component && s->component->getState(&stream) == kResultOk && stream.getSize() > 0)
comp.assign(stream.getData(), stream.getData() + stream.getSize());
}
if (!s->controllerIsComponent) {
Steinberg::MemoryStream stream;
if (s->controller && s->controller->getState(&stream) == kResultOk && stream.getSize() > 0)
ctrl.assign(stream.getData(), stream.getData() + stream.getSize());
}
if (comp.empty() && ctrl.empty()) return;
std::vector<uint8_t> blob;
auto append32 = [&blob](uint32_t v) {
blob.push_back((uint8_t)(v >> 24));
blob.push_back((uint8_t)(v >> 16));
blob.push_back((uint8_t)(v >> 8));
blob.push_back((uint8_t)v);
};
append32((uint32_t)comp.size());
blob.insert(blob.end(), comp.begin(), comp.end());
append32((uint32_t)ctrl.size());
blob.insert(blob.end(), ctrl.begin(), ctrl.end());
base64EncodeTo(blob.data(), blob.size(), out);
#endif
}
// Native editor: attach the plugin's IPlugView to a host window (must run
@@ -1103,6 +1159,7 @@ void RealtimeFxChain::Impl::workerLoop() {
// Worker (HTTP) thread -> pump thread editor-resize request. Declared
// outside the anonymous namespace: the definition sits after the namespace.
static void fxGuiResizeSafe(void* fx, HWND hwnd, int w, int h);
static void fxGuiCaptureSafe(void* fx, std::string& out);
namespace {
@@ -1116,6 +1173,16 @@ struct FxGuiCtx {
// checkSizeConstraint/onSize must run on the thread that owns the editor.
#define WM_FXGUI_RESIZE (WM_APP + 0x51)
// --- preset capture (GUI state -> project save) ------------------------
// getState must run on the pump thread that owns the plugin, so the HTTP
// worker posts WM_FXGUI_CAPTURE and waits (up to timeoutMs) for the pump
// thread to park the base64 blob. Empty outB64 = timeout or no state.
#define WM_FXGUI_CAPTURE (WM_APP + 0x52)
static std::mutex& gCapMutex() { static std::mutex m; return m; }
static std::condition_variable& gCapCv() { static std::condition_variable cv; return cv; }
static std::string& gCapB64() { static std::string s; return s; }
static bool& gCapDone() { static bool b = false; return b; }
LRESULT CALLBACK FxGuiWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
FxGuiCtx* ctx = (FxGuiCtx*)GetWindowLongPtrA(hwnd, GWLP_USERDATA);
switch (msg) {
@@ -1128,6 +1195,17 @@ LRESULT CALLBACK FxGuiWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
case WM_FXGUI_RESIZE:
fxGuiResizeSafe((void*)GetPropA(hwnd, "FXGUI_FX"), hwnd, (int)wp, (int)lp);
return 0;
case WM_FXGUI_CAPTURE: {
std::string b64;
fxGuiCaptureSafe((void*)GetPropA(hwnd, "FXGUI_FX"), b64);
{
std::lock_guard<std::mutex> lk(gCapMutex());
gCapB64() = std::move(b64);
gCapDone() = true;
}
gCapCv().notify_one();
return 0;
}
default:
return DefWindowProcA(hwnd, msg, wp, lp);
}
@@ -1161,11 +1239,37 @@ static void fxGuiResizeSafe(void* fx, HWND hwnd, int w, int h) {
(void)fx; (void)hwnd; (void)w; (void)h;
#endif
}
static void fxGuiCaptureSafe(void* fx, std::string& out) {
#ifdef HAVE_VST3SDK
__try { static_cast<Vst3Fx*>(fx)->captureState(out); }
__except (EXCEPTION_EXECUTE_HANDLER) {}
#else
(void)fx; (void)out;
#endif
}
// Worker (HTTP) thread asks the GUI pump thread to resize the plugin view.
void fxGuiRequestResize(HWND hwnd, int w, int h) {
PostMessageA(hwnd, WM_FXGUI_RESIZE, (WPARAM)w, (LPARAM)h);
}
// Capture the GUI plugin state on the pump thread; blocks up to timeoutMs.
void fxGuiRequestCapture(void* hwndV, std::string& outB64, int timeoutMs) {
HWND hwnd = (HWND)hwndV;
{
std::lock_guard<std::mutex> lk(gCapMutex());
gCapDone() = false;
gCapB64().clear();
}
PostMessageA(hwnd, WM_FXGUI_CAPTURE, 0, 0);
{
std::unique_lock<std::mutex> lk(gCapMutex());
if (!gCapCv().wait_for(lk, std::chrono::milliseconds(timeoutMs), [] { return gCapDone(); }))
return; // timeout - outB64 stays empty
outB64 = gCapB64();
}
}
// --- split setup for server mode (startup speed) -----------------------------
// 1. Parse job { path, name } — fast. Returns 0 ok, 1 job/argument error.
static int fxGuiParseJob(const std::string& jobPath, std::string& path, std::string& name) {