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:
+307
-7
@@ -84,6 +84,23 @@ const ensureSonicInstrument = (ctx) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Carla bridge MIDI scheduling helper ────────────────────────────────────
|
||||
// Schedule note_on/note_off tới Carla (OSC qua backend) cho MIDI items khi
|
||||
// track dùng VSTi + có Carla local. Dùng chung cho mọi đường playback
|
||||
// (main timeline, local loop, piano roll, ghost notes) để MIDI item PHẢI 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 chạy NGOÀI (ứng dụng ngoài, user tự cài) — trong
|
||||
// WebAudio graph chỉ là pass-through (không thêm DSP): âm track đi thẳng.
|
||||
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 chạy 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 và 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 là 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 của track/soundfont khác
|
||||
// (trước đây cứng 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 chuyển từ VSTi sang instrument KHÔNG phải VST ──
|
||||
// (soundfont/GM/default). Nếu không, Carla vẫn chạy với VSTi cũ → MIDI vẫn
|
||||
// play qua Carla bridge (âm sai instrument + âm kẹt không dừng đượ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 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); }
|
||||
})();
|
||||
}
|
||||
};
|
||||
@@ -16692,6 +16893,51 @@ const App = () => {
|
||||
});
|
||||
}, [tracks, subTabs, sessionTabs, masteringSettings]);
|
||||
|
||||
// ── 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:
|
||||
// 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 lại app → khôi phục 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 thuần
|
||||
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ơ hội gửi 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 cầu: MIDI item PHẢI 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 nhảy
|
||||
@@ -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 cục ──
|
||||
// Desktop: lưu project ra THƯ MỤC CỦA HỆ ĐIỀU HÀNH (Documents/SonicForgeDAW/
|
||||
// Projects). Docker/headless: lưu lên Cloud (đã login) hoặc 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ư mục của hệ điều 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 nhập), ngược lại 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);
|
||||
|
||||
Reference in New Issue
Block a user