FIX: 6 lỗi âm thanh/Carla/temp-save/Ctrl-S/FX Chain Carla bridge

- Soundfont preview: hủy note đang chờ load soundfont (stopNote/stopAll/panic) — hết âm loop không dừng với MIDI Keyboard; preview dùng channel riêng (applyAITrackInstrument) — hết sai instrument
- Carla bridge: endpoint /carla-stop (all-notes-off OSC + terminate process) + stopBridge() khi track chuyển VSTi -> soundfont — hết âm play qua Carla cũ
- MIDI items play qua Carla khi VSTi loaded: scheduleCarlaNote route vào startTrackPlayback + startLocalTrackPlayback + ghost notes
- Tự động lưu temp khi tắt app: beforeunload/pagehide sendBeacon + autosave 30s + ghi storage/temp/autosave.json + khôi phục khi load lại
- Ctrl-S: desktop -> save-to-disk (Documents/SonicForgeDAW/Projects); docker -> Cloud/local
- FX Chain (Mastering + FX Rack): module Carla Bridge (VST FX) — load/openInCarla + stop/unload, pass-through trong graph
This commit is contained in:
2026-08-10 05:34:32 +00:00
parent d20932a68b
commit c08233a163
10 changed files with 622 additions and 45 deletions
+307 -7
View File
@@ -84,6 +84,23 @@ const ensureSonicInstrument = (ctx) => {
}
};
// Carla bridge MIDI scheduling helper
// Schedule note_on/note_off ti Carla (OSC qua backend) cho MIDI items khi
// track dùng VSTi + có Carla local. Dùng chung cho mi đưng playback
// (main timeline, local loop, piano roll, ghost notes) đ MIDI item PHI play
// qua Carla bridge khi VSTi đưc loaded.
const scheduleCarlaNote = (synthEngine, channel, pitch, velocity, startWallTime, durMs) => {
try {
if (!window.SonicCarlaMidi || !window.SonicCarlaMidi.shouldRoutePlayback(synthEngine)) return;
const ctx = getAudioContext();
const delay = Math.max(0, (startWallTime - ctx.currentTime) * 1000);
const carlaVel = Math.round((velocity || 0.8) * 127);
const carlaCh = (synthEngine && synthEngine.midi_channel !== undefined) ? synthEngine.midi_channel : (channel || 0);
setTimeout(function () { window.SonicCarlaMidi.noteOn(carlaCh, pitch, carlaVel); }, delay);
setTimeout(function () { window.SonicCarlaMidi.noteOff(carlaCh, pitch); }, delay + (durMs || 300) + 30);
} catch (e) { /* routing thất bại im lặng — soundfont vẫn chơi qua FluidSynth */ }
};
// Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project)
(function handleSfsDeepLink() {
@@ -992,6 +1009,11 @@ 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 chy NGOÀI (ng dng ngoài, user t cài) trong
// WebAudio graph ch là pass-through (không thêm DSP): âm track đi thng.
input.connect(output);
nodes = { passthrough: input };
} else {
// 'eq' or default: 4-band EQ (params.g1..g4 = band gains in dB)
const f1 = ctx.createBiquadFilter(); f1.type = 'lowshelf'; f1.frequency.value = clampF(100);
@@ -1134,6 +1156,11 @@ function rebuildMasteringGraph(activate, chainArray) {
Object.keys(eqProStore).forEach(k => { try { eqProStore[k].destroy && eqProStore[k].destroy(); } catch (e) { } });
Object.keys(eqProStore).forEach(k => delete eqProStore[k]);
activeMods.forEach(mod => {
if (mod.type === 'carla') {
// Carla Bridge = VST FX chy NGOÀI (Carla standalone) không có node
// WebAudio trong master chain: pass-through, prev gi nguyên.
return;
}
if (mod.type === 'eqpro') {
const m = createEqProModule(getAudioContext(), mod.params || {});
eqProStore[mod.id] = m;
@@ -10514,7 +10541,8 @@ const TRACK_FX_META = {
compressor: { name: 'Bus Compressor', icon: 'compress', color: '#fbbf24', sub: 'Glue & Punch' },
limiter: { name: 'Brickwall Limiter', icon: 'shield-half', color: '#f43f5e', sub: 'True-Peak 20:1' },
exciter: { name: 'Harmonic Exciter', icon: 'wand-2', color: '#c084fc', sub: 'Saturation & Air' },
rebalance: { name: 'Master Rebalance', icon: 'sliders-horizontal', color: '#38bdf8', sub: 'M/S Balance' }
rebalance: { name: 'Master Rebalance', icon: 'sliders-horizontal', color: '#38bdf8', sub: 'M/S Balance' },
carla: { name: 'Carla Bridge (VST FX)', icon: 'sliders', color: '#14b8a6', sub: 'Native VST audio processing' }
};
const TRACK_FX_DEFAULTS = {
eq: { g1: 0, g2: 0, g3: 0, g4: 0 },
@@ -10522,7 +10550,8 @@ const TRACK_FX_DEFAULTS = {
compressor: { threshold: -16, ratio: 3, makeup: 0 },
limiter: { ceiling: -1.0 },
exciter: { drive: 40 },
rebalance: { mid: 0, side: 0 }
rebalance: { mid: 0, side: 0 },
carla: { plugin: '', plugin_path: '' }
};
// EQ Pro canvas frame renderer (graphic_EQ_interactive_module.md §I-II): grid,
@@ -10992,6 +11021,13 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
const scopeMeterRRef = React.useRef(null);
const eqCurveRef = React.useRef(null);
const scopeStateRef = React.useRef({ L: null, R: null, head: 0, len: 0, tmpL: null, tmpR: null, freq: null });
// Carla Bridge (VST FX) danh sách VST đ load vào Carla
const [fxCarlaVsts, setFxCarlaVsts] = React.useState(null); // null = chưa load
React.useEffect(() => {
if (track && window.SonicAPI && window.SonicAPI.listPlugins) {
window.SonicAPI.listPlugins().then(d => setFxCarlaVsts((d && d.vst_instruments) || [])).catch(() => setFxCarlaVsts([]));
}
}, [track && track.id]);
if (!track) return null;
@@ -11350,6 +11386,56 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => {
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-lg">{slider('SIDE GAIN', ap.side, -12, 12, 0.1, '#22d3ee', v => setParams(activeIdx, { side: v }), v => `${v > 0 ? '+' : ''}${v.toFixed(1)} dB`)}</div>
</div>
)}
{activeMod && activeMod.type === 'carla' && activeIdx >= 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-[9px] text-teal-400 font-mono uppercase tracking-widest">CARLA BRIDGE VST FX CHỈNH SỬA ÂM THANH</span>
<span className="text-[9px] text-slate-500 font-mono">Carla chạy ngoài (user tự cài) · native GUI</span>
</div>
<div className="bg-slate-900/80 border border-teal-900/60 p-3 rounded-lg space-y-3">
<div className="flex items-end gap-2 flex-wrap">
<div className="flex-1 min-w-[220px]">
<div className="text-[9px] text-slate-500 font-mono mb-1">CHỌN VST FX (đã scan trên máy)</div>
<select
value={ap.plugin || ''}
onChange={e => setParams(activeIdx, { plugin: e.target.value, plugin_path: '' })}
className="w-full bg-slate-950 border border-slate-700 text-teal-300 rounded px-2 py-1.5 text-xs outline-none focus:border-teal-500"
>
<option value=""> Chọn VST FX </option>
{(fxCarlaVsts || []).map(v => <option key={v.id || v.name} value={v.id || v.name}>{v.name || v.id}</option>)}
</select>
</div>
<button
onClick={() => {
if (!ap.plugin) { window.showToast && window.showToast('Chọn VST FX trước khi Load Carla', 'warning'); return; }
window.SonicAPI.openInCarla(ap.plugin, ap.plugin_path).then(r => {
if (r && r.success) { window.showToast && window.showToast('Đã mở Carla với ' + ap.plugin + ' (VST FX) — chỉnh âm thanh trong Carla', 'success'); }
else { window.showToast && window.showToast('Không mở được Carla', 'error'); }
}).catch(err => window.showToast && window.showToast('Lỗi mở Carla: ' + (err.message || err), 'error'));
}}
className="px-4 py-1.5 bg-teal-700 hover:bg-teal-600 text-white text-xs font-semibold rounded transition flex items-center gap-1 shrink-0"
>
<i data-lucide="play" className="w-3 h-3"></i> Load Carla Bridge
</button>
<button
onClick={() => {
if (window.SonicCarlaMidi && window.SonicCarlaMidi.stopBridge) window.SonicCarlaMidi.stopBridge();
window.showToast && window.showToast('Đã ngắt kết nối Carla Bridge', 'info');
}}
className="px-4 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-red-300 text-xs font-semibold rounded transition flex items-center gap-1 shrink-0"
>
<i data-lucide="square" className="w-3 h-3"></i> Stop / Unload
</button>
</div>
<div className="text-[10px] text-slate-500 font-mono leading-relaxed">
{ap.plugin
? <>Đã chọn: <span className="text-teal-300">{ap.plugin}</span> bấm <b>Load Carla Bridge</b> để mở native GUI VST chỉnh sửa âm thanh.</>
: 'Chọn VST FX từ danh sách đã scan, rồi bấm Load Carla Bridge để mở Carla (native GUI).'}
</div>
</div>
</div>
)}
</div>
{/* WAVE OBSERVER — REAL-TIME OSCILLOSCOPE (unified_fx_rack_panel_update.md §III.3) */}
@@ -11908,6 +11994,13 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
// stable hook count across renders (error #310 otherwise).
const dragChainIndexRef = React.useRef(null);
const [addModuleOpen, setAddModuleOpen] = React.useState(false);
// Carla Bridge (VST FX) danh sách VST đ load vào Carla (master chain)
const [masterCarlaVsts, setMasterCarlaVsts] = React.useState(null); // null = chưa load
React.useEffect(() => {
if (isOpen && window.SonicAPI && window.SonicAPI.listPlugins) {
window.SonicAPI.listPlugins().then(d => setMasterCarlaVsts((d && d.vst_instruments) || [])).catch(() => setMasterCarlaVsts([]));
}
}, [isOpen]);
if (!isOpen) return null;
@@ -11922,9 +12015,10 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
compressor: { name: 'Bus Compressor', sub: 'Glue & Punch', icon: 'compress', color: '#fbbf24' },
limiter: { name: 'Brickwall Limiter', sub: 'True-Peak 20:1', icon: 'shield-half', color: '#f43f5e' },
exciter: { name: 'Harmonic Exciter', sub: 'Saturation & Air', icon: 'wand-2', color: '#c084fc' },
rebalance: { name: 'Master Rebalance', sub: 'M/S Balance', icon: 'sliders-horizontal', color: '#38bdf8' }
rebalance: { name: 'Master Rebalance', sub: 'M/S Balance', icon: 'sliders-horizontal', color: '#38bdf8' },
carla: { name: 'Carla Bridge (VST FX)', sub: 'Native VST audio processing', icon: 'sliders', color: '#14b8a6' }
};
const chainFlag = (type) => type === 'eq' ? 'eqActive' : type === 'eqpro' ? 'eqproActive' : type === 'imager' ? 'imagerActive' : type === 'maximizer' ? 'maximizerActive' : type === 'compressor' ? 'compActive' : type === 'limiter' ? 'limActive' : type === 'exciter' ? 'excActive' : 'rebalActive';
const chainFlag = (type) => type === 'eq' ? 'eqActive' : type === 'eqpro' ? 'eqproActive' : type === 'imager' ? 'imagerActive' : type === 'maximizer' ? 'maximizerActive' : type === 'compressor' ? 'compActive' : type === 'limiter' ? 'limActive' : type === 'exciter' ? 'excActive' : type === 'carla' ? 'carlaBridgeActive' : 'rebalActive';
const chainActive = (type) => !!ozState[chainFlag(type)];
const toggleChainModule = (modId) => {
@@ -11963,6 +12057,7 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
const id = 'mod_' + type + '_' + Date.now();
const entry = { id, type, name: meta.name, active: true };
if (type === 'eqpro') entry.params = { amount: 100, bands: JSON.parse(JSON.stringify(EQPRO_DEFAULT_BANDS)) };
if (type === 'carla') entry.params = { plugin: '', plugin_path: '' };
setOzState(prev => ({
...prev,
chain: [...(prev.chain || []), entry],
@@ -12416,6 +12511,62 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin
</div>
</div>
{/* VIEW: CARLA BRIDGE (VST FX) — load carla bridge để dùng VST chỉnh sửa âm thanh */}
<div className={`flex-1 flex flex-col p-4 gap-4 ${ozState.activeModule === 'carla' ? '' : 'hidden'}`}>
<div className="oz-panel p-4 rounded-xl flex-1 grid grid-cols-12 gap-6 items-center">
<div className="col-span-4 flex flex-col items-center justify-center border-r border-slate-800 pr-4">
<span className="text-xs font-bold text-teal-400 uppercase oz-font-mono mb-3">Carla Bridge (VST FX)</span>
<button onClick={() => toggleChainModule((ozState.chain || []).find(m => m.type === 'carla')?.id)}
className={`px-3 py-1 rounded text-[10px] font-bold border transition-colors mt-2 ${ozState.carlaBridgeActive ? 'bg-teal-700 border-teal-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-300'}`}>
{ozState.carlaBridgeActive ? 'ON' : 'OFF'}
</button>
</div>
<div className="col-span-8 space-y-3">
<div className="flex items-end gap-2 flex-wrap">
<div className="flex-1 min-w-[220px]">
<div className="text-[9px] text-slate-500 oz-font-mono mb-1">CHỌN VST FX (đã scan trên máy)</div>
<select
value={(ozState.carlaBridge && ozState.carlaBridge.plugin) || ''}
onChange={e => setOzState(prev => ({ ...prev, carlaBridge: { ...(prev.carlaBridge || {}), plugin: e.target.value, plugin_path: '' } }))}
className="w-full bg-slate-950 border border-slate-700 text-teal-300 rounded px-2 py-1.5 text-xs outline-none focus:border-teal-500"
>
<option value=""> Chọn VST FX </option>
{(masterCarlaVsts || []).map(v => <option key={v.id || v.name} value={v.id || v.name}>{v.name || v.id}</option>)}
</select>
</div>
<button
onClick={() => {
const plugin = (ozState.carlaBridge && ozState.carlaBridge.plugin) || '';
if (!plugin) { window.showToast && window.showToast('Chọn VST FX trước khi Load Carla', 'warning'); return; }
window.SonicAPI.openInCarla(plugin, ozState.carlaBridge.plugin_path).then(r => {
if (r && r.success) {
setOzState(prev => ({ ...prev, carlaBridge: { ...(prev.carlaBridge || {}), connected: true } }));
window.showToast && window.showToast('Đã mở Carla với ' + plugin + ' (VST FX) — chỉnh âm thanh trong Carla', 'success');
} else { window.showToast && window.showToast('Không mở được Carla', 'error'); }
}).catch(err => window.showToast && window.showToast('Lỗi mở Carla: ' + (err.message || err), 'error'));
}}
className="px-4 py-1.5 bg-teal-700 hover:bg-teal-600 text-white text-xs font-semibold rounded transition flex items-center gap-1 shrink-0"
>
<i data-lucide="play" className="w-3 h-3"></i> Load Carla Bridge
</button>
<button
onClick={() => {
if (window.SonicCarlaMidi && window.SonicCarlaMidi.stopBridge) window.SonicCarlaMidi.stopBridge();
setOzState(prev => ({ ...prev, carlaBridge: { ...(prev.carlaBridge || {}), connected: false } }));
window.showToast && window.showToast('Đã ngắt kết nối Carla Bridge', 'info');
}}
className="px-4 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-red-300 text-xs font-semibold rounded transition flex items-center gap-1 shrink-0"
>
<i data-lucide="square" className="w-3 h-3"></i> Stop / Unload
</button>
</div>
<div className="text-[10px] text-slate-500 oz-font-mono leading-relaxed">
Mở Carla với VST FX để chỉnh sửa âm thanh master (native GUI). Module này pass-through trong master chain (không thêm DSP WebAudio).
</div>
</div>
</div>
</div>
{/* WAVE OBSERVER INTEGRATION */}
<div className="border-t border-slate-800 bg-slate-900/60 p-3 flex flex-col shrink-0">
{/* Header */}
@@ -13663,8 +13814,15 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
const program = curInst ? curInst.program : undefined;
const sfId = curInst ? curInst.sfId : undefined;
const bank = curInst ? curInst.bank : 0;
if (curInst && window.SonicSF.selectInstrument) {
try { await window.SonicSF.selectInstrument(0, bank, program, sfId); } catch (e2) {}
// Preview dùng channel RIÊNG (qua _engineChMap) KHÔNG đè channel 0 mà
// track đang dùng không làm sai instrument ca track/soundfont khác
// (trưc đây cng channel 0: preview MIDI Keyboard có th chơi sai
// instrument khi track khác đang dùng chung channel).
let pvCh = 0;
if (curInst && window.SonicSF.applyAITrackInstrument) {
try { pvCh = window.SonicSF.applyAITrackInstrument(bank, program, { soundfont_id: sfId, soundfont_bank: bank, soundfont_program: program !== undefined ? program : 0 }) || 0; } catch (e2) { pvCh = 0; }
} else if (curInst && window.SonicSF.selectInstrument) {
try { await window.SonicSF.selectInstrument(pvCh, bank, program, sfId); } catch (e2) {}
}
// Re-check token after the async await stale playMidiPreview (older file)
// must not schedule notes over the newly selected file.
@@ -13695,7 +13853,7 @@ const MediaExplorerPanel = ({ height, clipboardRef, active }) => {
const shiftedStartBeat = hasSelection ? (noteStartBeat - loopStartBeats) : noteStartBeat;
const startSec = shiftedStartBeat * secondsPerBeat + (note.trackOffset || 0);
const durMs = Math.max(80, (note.duration_beats || 1) * secondsPerBeat * 1000);
window.SonicSF.playNote(note.pitch || 60, (note.velocity || 0.8), durMs, passStartTime + startSec, prog, null, 0, eng);
window.SonicSF.playNote(note.pitch || 60, (note.velocity || 0.8), durMs, passStartTime + startSec, prog, null, pvCh, eng);
});
};
schedulePass(startWallTime);
@@ -14835,6 +14993,19 @@ const App = () => {
var mt = activeTracksRef.current || tracks;
var curTrk = null;
for (var ci = 0; ci < mt.length; ci++) { if (mt[ci].id === trackId) { curTrk = mt[ci]; break; } }
// Unload Carla bridge khi chuyn t VSTi sang instrument KHÔNG phi VST
// (soundfont/GM/default). Nếu không, Carla vn chy vi VSTi cũ MIDI vn
// play qua Carla bridge (âm sai instrument + âm kt không dng đưc).
try {
const _hasInst = !!instrumentId;
const _wasVst = curTrk && curTrk.synth_engine && String(curTrk.synth_engine.type || '').indexOf('vst') !== -1;
const _nowVst = _hasInst && !isSfInstrument;
if (_wasVst && !_nowVst && window.SonicCarlaMidi && window.SonicCarlaMidi.stopBridge) {
window.SonicCarlaMidi.stopBridge();
try { if (window.SonicSF && window.SonicSF.stopAll) window.SonicSF.stopAll(); } catch (e) {}
console.log('[Instrument] Carla bridge unloaded — track', trackId, 'switched from VSTi to non-VST');
}
} catch (e) { console.warn('[Instrument] carla-stop on instrument switch error:', e); }
var mch = curTrk ? assignTrackMidiChannel(curTrk, mt) : (sfBank === 128 ? 9 : 0);
updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
@@ -16565,6 +16736,7 @@ const App = () => {
limActive: false, limThreshold: -1.0,
excActive: false, excDrive: 40,
rebalActive: false, rebalMid: 0, rebalSide: 0,
carlaBridgeActive: false, carlaBridge: { plugin: '', plugin_path: '', connected: false },
});
}
} else {
@@ -16655,6 +16827,35 @@ const App = () => {
setInstrumentSelectorData(data);
}).catch(() => {});
} catch (e) { }
// Khôi phc d án đang làm d (temp autosave) khi load li app
// Yêu cu: "Khi tt ng dng t đng lưu temp; khi load li ti li
// d án đang làm d." Ch restore khi app khi đng vi project TRNG
// (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); }
})();
}
};
@@ -16692,6 +16893,51 @@ const App = () => {
});
}, [tracks, subTabs, sessionTabs, masteringSettings]);
// Temp autosave SERVER khi tt ng dng (Bug: đóng app mt d án làm d)
// Lưu temp vào thư mc temp ca ng dng (storage/temp + DB) khi:
// 1) đóng tab/window (beforeunload/pagehide sendBeacon keepalive)
// 2) đnh k 30s khi có thay đi (phòng Tauri kill engine ngay khi close)
// Load li app khôi phc d án đang làm d (getTempProject mount).
useEffect(() => {
const buildTempPayload = () => {
try {
const schemaObj = serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs, masteringSettings);
return JSON.stringify({ data_json: JSON.stringify(schemaObj) });
} catch (e) { return null; }
};
const saveTempServer = () => {
const payload = buildTempPayload();
if (!payload) return;
try {
if (navigator.sendBeacon) {
// sendBeacon không đt header JSON đưc backend đc body JSON thun
const blob = new Blob([payload], { type: 'application/json' });
navigator.sendBeacon(window.API_BASE_URL + '/api/v1/projects/temp', blob);
} else {
fetch(window.API_BASE_URL + '/api/v1/projects/temp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true
}).catch(() => {});
}
} catch (e) { /* fire-and-forget */ }
};
const onUnload = (e) => {
saveTempServer();
// Đ sendBeacon có cơ hi gi trưc khi WebView b destroy
try { navigator.sendBeacon && navigator.sendBeacon(window.API_BASE_URL + '/health', new Blob(['ping'], { type: 'text/plain' })); } catch (e2) {}
};
window.addEventListener('beforeunload', onUnload);
window.addEventListener('pagehide', onUnload);
const interval = setInterval(saveTempServer, 30 * 1000);
return () => {
window.removeEventListener('beforeunload', onUnload);
window.removeEventListener('pagehide', onUnload);
clearInterval(interval);
};
}, [currentProjectId, projectName, bpm, tracks, subTabs, sessionTabs, masteringSettings]);
// Timer-based auto-save (5 min) + backup (30 min)
useEffect(() => {
const BACKUP_MAX_KEY = 'sonic_backup_max_count';
@@ -20843,6 +21089,9 @@ const App = () => {
trkCh,
track.synth_engine
);
// MIDI items Carla bridge (track VSTi + Carla local): phát VSTi
// realtime yêu cu: MIDI item PHI play qua Carla khi VSTi loaded.
scheduleCarlaNote(track.synth_engine, trkCh, note.pitch || 60, note.velocity || 0.8, startTime, durationMs);
// Trigger VU meter flash when the note starts playing
setTimeout(() => {
// Guard: stop setTimeout sót không đưc fire (VU nhy
@@ -20866,6 +21115,8 @@ const App = () => {
trkCh,
track.synth_engine
);
// MIDI items Carla bridge (track VSTi + Carla local)
scheduleCarlaNote(track.synth_engine, trkCh, note.pitch || 60, note.velocity || 0.8, context.currentTime, remainingDurMs);
// Trigger VU meter flash instantly
if (window.triggerMidiVuActivity) {
window.triggerMidiVuActivity((activeTab && activeTab.startsWith('session_') ? '_sess_' : '') + track.id, note.velocity || 0.8);
@@ -21092,6 +21343,8 @@ const App = () => {
lcCh,
track.synth_engine
);
// MIDI items Carla bridge (track VSTi + Carla local)
scheduleCarlaNote(track.synth_engine, lcCh, note.pitch || 60, note.velocity || 0.8, startTime, durationMs);
} else {
const remainingDurMs = (noteEndSec - offsetTime) * 1000;
window.SonicSF.playNote(
@@ -21104,6 +21357,8 @@ const App = () => {
lcCh,
track.synth_engine
);
// MIDI items Carla bridge (track VSTi + Carla local)
scheduleCarlaNote(track.synth_engine, lcCh, note.pitch || 60, note.velocity || 0.8, context.currentTime, remainingDurMs);
}
}
});
@@ -21183,6 +21438,8 @@ const App = () => {
if (window.SonicSF) {
window.SonicSF.playNote(note.pitch || 60, note.velocity || 0.8, durMs, schedTime, ghostProg, ghostDest, ghostCh, ghostSynth);
}
// Ghost notes Carla bridge (ghost track VSTi + Carla local)
scheduleCarlaNote(ghostSynth, ghostCh, note.pitch || 60, note.velocity || 0.8, schedTime, durMs);
}
});
});
@@ -24131,6 +24388,49 @@ const App = () => {
const handleSaveProjectRef = useRef(handleSaveProject);
handleSaveProjectRef.current = handleSaveProject;
// Ctrl-S (Save) toàn cc
// Desktop: lưu project ra THƯ MC CA H ĐIU HÀNH (Documents/SonicForgeDAW/
// Projects). Docker/headless: lưu lên Cloud (đã login) hoc local (.sfs).
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 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ư mc ca h điu hành
const res = await window.SonicAPI.saveProjectToDisk(finalName, dataJson);
showToast('Đã lưu dự án: ' + ((res && res.path) || finalName), 'success');
} else if (window.SonicAPI) {
// Docker / headless Cloud (nếu đã đăng nhp), ngưc li local
if (currentProjectId && !currentProjectId.startsWith('local_') && currentUser) {
await window.SonicAPI.updateCloudProject(currentProjectId, finalName, dataJson);
showToast('Đã lưu dự án lên Cloud', 'success');
} else if (currentUser) {
await handleSaveAsCloud(finalName);
} else {
handleSaveLocalProject(finalName);
}
}
} catch (err) {
showToast('Lỗi lưu dự án: ' + (err.message || err), 'error');
}
};
const handleGlobalSaveRef = useRef(handleGlobalSave);
handleGlobalSaveRef.current = handleGlobalSave;
useEffect(() => {
const handler = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
const target = e.target;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
e.preventDefault();
handleGlobalSaveRef.current();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
const handleSaveAsCloud = async (newName) => {
const projectSchemaObj = serializeProjectToSchema('project_' + Date.now(), newName, bpm, tracks, subTabs, sessionTabs, masteringSettings);
const dataJson = JSON.stringify(projectSchemaObj);