fix(latency): threadpool renders + RENDER_LOCK + client pre-render cache

This commit is contained in:
2026-08-16 20:21:48 +07:00
parent 90b5a63719
commit ed26e1706d
6 changed files with 139 additions and 42 deletions
+3 -2
View File
@@ -6,7 +6,7 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, Any from typing import Optional, Any
from app.config import settings from app.config import settings
from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH, apply_preset_to_plugin, render_soundfont_midi_to_audio from app.core.vst_engine import PluginManager, HAS_PEDALBOARD, HAS_PYFLUIDSYNTH, apply_preset_to_plugin, render_soundfont_midi_to_audio, RENDER_LOCK, _render_locked
from app.core.render_engine import PythonRenderEngine, _find_sf2_path, _find_default_sf2 from app.core.render_engine import PythonRenderEngine, _find_sf2_path, _find_default_sf2
from app.core.soundfont_inspector import SoundFontInspector from app.core.soundfont_inspector import SoundFontInspector
from app.core.soundfont_converter import SoundFontConverter from app.core.soundfont_converter import SoundFontConverter
@@ -975,6 +975,7 @@ async def midi_render(req: MidiRenderRequest, current_user: dict = Depends(get_c
) )
@_render_locked
def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float, def _render_midi_notes_pedalboard(instrument_id: str, notes: list, bpm: float,
sample_rate: int, preset_id=None, preset_path=None, sample_rate: int, preset_id=None, preset_path=None,
preset_data_b64=None, soundfont_bank=None, preset_data_b64=None, soundfont_bank=None,
@@ -1162,7 +1163,7 @@ def _carla_bridge_running() -> bool:
@router.post("/render") @router.post("/render")
async def render_project( def render_project(
req: RenderRequest, req: RenderRequest,
current_user: dict = Depends(get_current_user) current_user: dict = Depends(get_current_user)
): ):
+26 -24
View File
@@ -11,6 +11,7 @@ from app.core.vst_engine import (
HAS_PEDALBOARD, HAS_PEDALBOARD,
HAS_PYFLUIDSYNTH, HAS_PYFLUIDSYNTH,
apply_preset_to_plugin, apply_preset_to_plugin,
RENDER_LOCK,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -451,30 +452,31 @@ class PythonRenderEngine:
return session_buffer return session_buffer
def render_project(self, project_json: dict, output_filepath: str): def render_project(self, project_json: dict, output_filepath: str):
bpm = project_json["metadata"]["bpm"] with RENDER_LOCK:
time_sig_num = project_json["metadata"].get("time_signature_numerator", 4) bpm = project_json["metadata"]["bpm"]
main_session = project_json["main_session"] time_sig_num = project_json["metadata"].get("time_signature_numerator", 4)
section_store = project_json.get("section_store", {}) main_session = project_json["main_session"]
section_store = project_json.get("section_store", {})
# Compute total project samples # Compute total project samples
total_bars = main_session.get("length_bars", 16.0) total_bars = main_session.get("length_bars", 16.0)
total_samples = self.bars_to_samples(total_bars, bpm, time_sig_num) total_samples = self.bars_to_samples(total_bars, bpm, time_sig_num)
# Render main session
master_buffer = self.render_session_container(
session=main_session,
section_store=section_store,
bpm=bpm,
time_sig_num=time_sig_num,
total_samples=total_samples,
_cache={},
)
# Normalization to prevent clipping # Render main session
max_peak = np.max(np.abs(master_buffer)) master_buffer = self.render_session_container(
if max_peak > 1.0: session=main_session,
master_buffer /= max_peak section_store=section_store,
bpm=bpm,
time_sig_num=time_sig_num,
total_samples=total_samples,
_cache={},
)
# Write final output file # Normalization to prevent clipping
sf.write(output_filepath, master_buffer.T, self.sample_rate) max_peak = np.max(np.abs(master_buffer))
return output_filepath if max_peak > 1.0:
master_buffer /= max_peak
# Write final output file
sf.write(output_filepath, master_buffer.T, self.sample_rate)
return output_filepath
+16
View File
@@ -3,8 +3,21 @@ import os
import json import json
import numpy as np import numpy as np
import functools import functools
import threading
from ctypes import c_char_p from ctypes import c_char_p
# pedalboard VST3 + pyfluidsynth KHÔNG thread-safe: tuần tự hóa mọi native render
RENDER_LOCK = threading.RLock()
def _render_locked(fn):
"""Chạy hàm native (VST3/pyfluidsynth) dưới RENDER_LOCK — không thread-safe."""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
with RENDER_LOCK:
return fn(*args, **kwargs)
return wrapper
def _ensure_fluidsynth_dll_on_path(): def _ensure_fluidsynth_dll_on_path():
"""Frozen (PyInstaller): pyfluidsynth tim dll qua ctypes.util.find_library """Frozen (PyInstaller): pyfluidsynth tim dll qua ctypes.util.find_library
@@ -25,6 +38,7 @@ def midi_note_to_freq(note_number: int) -> float:
return 440.0 * (2.0 ** ((note_number - 69) / 12.0)) return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
@_render_locked
def render_soundfont_midi_to_audio(midi_events: list, sf_path: str, bank: int = 0, def render_soundfont_midi_to_audio(midi_events: list, sf_path: str, bank: int = 0,
program: int = 0, sr: int = 44100, bpm: float = 120.0) -> np.ndarray: program: int = 0, sr: int = 44100, bpm: float = 120.0) -> np.ndarray:
"""Native FluidSynth (pyfluidsynth) — render MIDI events bằng soundfont THẬT. """Native FluidSynth (pyfluidsynth) — render MIDI events bằng soundfont THẬT.
@@ -92,6 +106,7 @@ def render_soundfont_midi_to_audio(midi_events: list, sf_path: str, bank: int =
except Exception: except Exception:
pass pass
@_render_locked
def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float = 120.0, instrument: str = 'synth') -> np.ndarray: def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float = 120.0, instrument: str = 'synth') -> np.ndarray:
beat_duration_sec = 60.0 / max(30.0, bpm) beat_duration_sec = 60.0 / max(30.0, bpm)
max_duration_sec = 2.0 max_duration_sec = 2.0
@@ -336,6 +351,7 @@ class PluginManager:
} }
return list(sf_map.values()) return list(sf_map.values())
@_render_locked
def load_vst(self, plugin_name: str, preset_data: dict = None): def load_vst(self, plugin_name: str, preset_data: dict = None):
if not HAS_PEDALBOARD: if not HAS_PEDALBOARD:
return None return None
+85 -11
View File
@@ -344,6 +344,79 @@ const stopNativeVstNote = (key) => {
const stopAllNativeVstNotes = () => { const stopAllNativeVstNotes = () => {
try { Object.keys(_nativeVstPreviews).forEach(function (k) { stopNativeVstNote(k); }); } catch (e) {} try { Object.keys(_nativeVstPreviews).forEach(function (k) { stopNativeVstNote(k); }); } catch (e) {}
}; };
// URL cache cho midi-render: key = fingerprint (item+instrument+preset+notes)
// → re-play cùng item dùng lại WAV đã render, không chờ round-trip.
const _nativeVstItemUrls = {};
const _nativeVstItemKey = (track, item, bpm) => {
try {
const eng = track && track.synth_engine;
const instrumentId = (eng && eng.plugin_id) || (track && track.instrumentId) || '';
const notes = (item && item.notes) || [];
const nk = notes.map(function (n) {
return (n.pitch || 60) + ':' + (n.start_beat || 0) + ':' + (n.duration_beats || 1) + ':' + (n.velocity != null ? n.velocity : 0.8);
}).join(',');
return [item && item.id ? item.id : (instrumentId + ':' + nk), instrumentId, (eng && eng.preset_id) || '', (eng && eng.preset_path) || '', bpm].join('|') + '|' + nk;
} catch (e) { return ''; }
};
const _renderNativeVstItem = (track, item, bpm) => {
try {
const eng = track && track.synth_engine;
const instrumentId = (eng && eng.plugin_id) || (track && track.instrumentId);
if (!instrumentId || !isVstTrackEngine(eng)) return Promise.resolve(null);
const notes = (item && item.notes) || [];
if (!notes.length) return Promise.resolve(null);
return window.SonicAPI.midiRender({
instrument_id: instrumentId,
notes: notes.map(function (n) { return { pitch: n.pitch || 60, start_beat: n.start_beat || 0, duration_beats: n.duration_beats || 1, velocity: n.velocity != null ? n.velocity : 0.8 }; }),
bpm: parseFloat(bpm) || 120,
preset_id: (eng && eng.preset_id) || undefined,
preset_path: (eng && eng.preset_path) || undefined,
preset_data: (eng && eng.preset_data) || undefined,
}).then(function (res) {
if (!res || !res.success || !res.url) return null;
return API_BASE_URL + res.url;
}).catch(function () { return null; });
} catch (e) { return Promise.resolve(null); }
};
// Dedupe in-flight render: play trong lúc pre-render đang chạy → chờ cùng promise,
// không bắn duplicate request (tránh queue sau RENDER_LOCK + render 2 lần).
const _nativeVstItemPending = {};
const ensureRenderNativeVstItem = (track, item, bpm) => {
const key = _nativeVstItemKey(track, item, bpm);
if (!key) return _renderNativeVstItem(track, item, bpm);
if (_nativeVstItemUrls[key]) return Promise.resolve(_nativeVstItemUrls[key]);
if (_nativeVstItemPending[key]) return _nativeVstItemPending[key];
const p = _renderNativeVstItem(track, item, bpm).then(function (url) {
if (url) _nativeVstItemUrls[key] = url;
return url;
}).then(function (url) {
delete _nativeVstItemPending[key];
return url;
});
_nativeVstItemPending[key] = p;
return p;
};
let _prerenderNativeVstTimer = null;
const prerenderNativeVstItems = (tracks, bpm) => {
try {
if (!isStandaloneSf()) return;
if (_prerenderNativeVstTimer) { clearTimeout(_prerenderNativeVstTimer); _prerenderNativeVstTimer = null; }
_prerenderNativeVstTimer = setTimeout(function () {
_prerenderNativeVstTimer = null;
const seen = {};
(tracks || []).forEach(function (track) {
const nativeVst = isStandaloneSf() && isVstTrackEngine(track && track.synth_engine) && !(window.SonicMidiRouter && window.SonicMidiRouter.isBridgeActive());
if (!nativeVst) return;
(track.midiItems || []).forEach(function (item) {
const key = _nativeVstItemKey(track, item, bpm);
if (!key || seen[key] || _nativeVstItemUrls[key]) return;
seen[key] = true;
ensureRenderNativeVstItem(track, item, bpm).then(function () {});
});
});
}, 800);
} catch (e) {}
};
// ── Native VSTi item render (standalone, transport) ──────────────────────── // ── Native VSTi item render (standalone, transport) ────────────────────────
// Render TOÀN BỘ MIDI item bằng pedalboard (VST3) → decode AudioBuffer → // Render TOÀN BỘ MIDI item bằng pedalboard (VST3) → decode AudioBuffer →
@@ -363,16 +436,10 @@ const scheduleNativeVstItem = (track, item, offsetTime, context, destNode, bpm,
const end = itemStartAbs + ((n.start_beat || 0) + (n.duration_beats || 1)) * secPerBeat; const end = itemStartAbs + ((n.start_beat || 0) + (n.duration_beats || 1)) * secPerBeat;
if (end > itemEndAbs) itemEndAbs = end; if (end > itemEndAbs) itemEndAbs = end;
}); });
window.SonicAPI.midiRender({ const key = _nativeVstItemKey(track, item, bpm);
instrument_id: instrumentId, const scheduleUrl = function (url) {
notes: notes.map(function (n) { return { pitch: n.pitch || 60, start_beat: n.start_beat || 0, duration_beats: n.duration_beats || 1, velocity: n.velocity != null ? n.velocity : 0.8 }; }), if (!url) return;
bpm: parseFloat(bpm) || 120, fetch(url).then(function (r) { return r.arrayBuffer(); }).then(function (buf) {
preset_id: (eng && eng.preset_id) || undefined,
preset_path: (eng && eng.preset_path) || undefined,
preset_data: (eng && eng.preset_data) || undefined,
}).then(function (res) {
if (!res || !res.success || !res.url) return;
fetch(API_BASE_URL + res.url).then(function (r) { return r.arrayBuffer(); }).then(function (buf) {
context.decodeAudioData(buf, function (audioBuf) { context.decodeAudioData(buf, function (audioBuf) {
try { try {
if (opts && typeof opts.isActive === 'function' && !opts.isActive()) return; if (opts && typeof opts.isActive === 'function' && !opts.isActive()) return;
@@ -393,7 +460,12 @@ const scheduleNativeVstItem = (track, item, offsetTime, context, destNode, bpm,
} catch (e) { console.warn('[NativeVST] schedule decode error:', e); } } catch (e) { console.warn('[NativeVST] schedule decode error:', e); }
}, function () {}); }, function () {});
}).catch(function () {}); }).catch(function () {});
}).catch(function () {}); };
if (key && _nativeVstItemUrls[key]) { scheduleUrl(_nativeVstItemUrls[key]); return; }
ensureRenderNativeVstItem(track, item, bpm).then(function (url) {
if (!url) return;
scheduleUrl(url);
});
} catch (e) { console.warn('[NativeVST] scheduleNativeVstItem error:', e); } } catch (e) { console.warn('[NativeVST] scheduleNativeVstItem error:', e); }
}; };
@@ -17506,6 +17578,7 @@ const App = () => {
loadAudioBuffersForTracks(restoredTracks).catch(function(err) { console.warn('loadAudioBuffersForTracks error:', err); }); loadAudioBuffersForTracks(restoredTracks).catch(function(err) { console.warn('loadAudioBuffersForTracks error:', err); });
trackMidiChannelsRef.current = {}; trackMidiChannelsRef.current = {};
preloadTrackInstruments(restoredTracks).catch(function(err) { console.warn('preloadTrackInstruments error:', err); }); preloadTrackInstruments(restoredTracks).catch(function(err) { console.warn('preloadTrackInstruments error:', err); });
prerenderNativeVstItems(restoredTracks, restoredBpm);
setBpm(restoredBpm.toString()); setBpm(restoredBpm.toString());
setSelectedTrackId(restoredTracks[0]?.id || '1'); setSelectedTrackId(restoredTracks[0]?.id || '1');
setProjectName(lastName); setProjectName(lastName);
@@ -17644,6 +17717,7 @@ const App = () => {
if (restoredTracks.length > 0) { if (restoredTracks.length > 0) {
setTracks(restoredTracks); setTracks(restoredTracks);
setBpm(restoredBpm.toString()); setBpm(restoredBpm.toString());
prerenderNativeVstItems(restoredTracks, restoredBpm);
if (restoredSessionTabs.length > 0) { if (restoredSessionTabs.length > 0) {
setSessionTabs(restoredSessionTabs); setSessionTabs(restoredSessionTabs);
} }
+8 -4
View File
@@ -57,10 +57,14 @@ const scheduleNativeSfItem=(track,item,offsetTime,context,destNode,bpm,opts)=>{t
// Render 1 note bằng backend pedalboard (VST3 đúng instrument_id/preset) → // Render 1 note bằng backend pedalboard (VST3 đúng instrument_id/preset) →
// play WAV — giống hệt nativeSf nhưng qua midiRender; map token riêng. // play WAV — giống hệt nativeSf nhưng qua midiRender; map token riêng.
const _nativeVstPreviews={};const playNativeVstNote=(track,pitch,velocity,durationMs,startTime,key)=>{try{const eng=track&&track.synth_engine;const instrumentId=eng&&eng.plugin_id||track&&track.instrumentId;if(!instrumentId||!isVstTrackEngine(eng))return;const k=key||(track?track.id:'global');const prev=_nativeVstPreviews[k];const token=(prev?prev.token:0)+1;const durationSec=Math.max(0.2,(durationMs||500)/1000);window.SonicAPI.midiRender({instrument_id:instrumentId,notes:[{pitch:pitch,start_beat:0,duration_beats:durationSec*2,velocity:velocity!=null?velocity:0.8}],bpm:120,preset_id:eng&&eng.preset_id||undefined,preset_path:eng&&eng.preset_path||undefined,preset_data:eng&&eng.preset_data||undefined}).then(function(res){if(!res||!res.success||!res.url)return;if(_nativeVstPreviews[k]&&_nativeVstPreviews[k].token!==token)return;// stale const _nativeVstPreviews={};const playNativeVstNote=(track,pitch,velocity,durationMs,startTime,key)=>{try{const eng=track&&track.synth_engine;const instrumentId=eng&&eng.plugin_id||track&&track.instrumentId;if(!instrumentId||!isVstTrackEngine(eng))return;const k=key||(track?track.id:'global');const prev=_nativeVstPreviews[k];const token=(prev?prev.token:0)+1;const durationSec=Math.max(0.2,(durationMs||500)/1000);window.SonicAPI.midiRender({instrument_id:instrumentId,notes:[{pitch:pitch,start_beat:0,duration_beats:durationSec*2,velocity:velocity!=null?velocity:0.8}],bpm:120,preset_id:eng&&eng.preset_id||undefined,preset_path:eng&&eng.preset_path||undefined,preset_data:eng&&eng.preset_data||undefined}).then(function(res){if(!res||!res.success||!res.url)return;if(_nativeVstPreviews[k]&&_nativeVstPreviews[k].token!==token)return;// stale
const audio=new Audio(API_BASE_URL+res.url);_nativeVstPreviews[k]={audio:audio,token:token};const ctx=getAudioContext();const delay=startTime?Math.max(0,(startTime-ctx.currentTime)*1000):0;setTimeout(function(){if(!_nativeVstPreviews[k]||_nativeVstPreviews[k].audio!==audio)return;audio.play().catch(function(){});audio._vstStopTimer&&clearTimeout(audio._vstStopTimer);audio._vstStopTimer=setTimeout(function(){try{audio.pause();}catch(e){}},durationSec*1000+400);},delay);}).catch(function(){});}catch(e){console.warn('[NativeVST] playNativeVstNote error:',e);}};const stopNativeVstNote=key=>{try{const k=key||'global';const prev=_nativeVstPreviews[k];if(!prev)return;prev.token++;try{if(prev.audio){prev.audio.pause();prev.audio.currentTime=0;}}catch(e){}}catch(e){}};const stopAllNativeVstNotes=()=>{try{Object.keys(_nativeVstPreviews).forEach(function(k){stopNativeVstNote(k);});}catch(e){}};// ── Native VSTi item render (standalone, transport) ──────────────────────── const audio=new Audio(API_BASE_URL+res.url);_nativeVstPreviews[k]={audio:audio,token:token};const ctx=getAudioContext();const delay=startTime?Math.max(0,(startTime-ctx.currentTime)*1000):0;setTimeout(function(){if(!_nativeVstPreviews[k]||_nativeVstPreviews[k].audio!==audio)return;audio.play().catch(function(){});audio._vstStopTimer&&clearTimeout(audio._vstStopTimer);audio._vstStopTimer=setTimeout(function(){try{audio.pause();}catch(e){}},durationSec*1000+400);},delay);}).catch(function(){});}catch(e){console.warn('[NativeVST] playNativeVstNote error:',e);}};const stopNativeVstNote=key=>{try{const k=key||'global';const prev=_nativeVstPreviews[k];if(!prev)return;prev.token++;try{if(prev.audio){prev.audio.pause();prev.audio.currentTime=0;}}catch(e){}}catch(e){}};const stopAllNativeVstNotes=()=>{try{Object.keys(_nativeVstPreviews).forEach(function(k){stopNativeVstNote(k);});}catch(e){}};// URL cache cho midi-render: key = fingerprint (item+instrument+preset+notes)
// → re-play cùng item dùng lại WAV đã render, không chờ round-trip.
const _nativeVstItemUrls={};const _nativeVstItemKey=(track,item,bpm)=>{try{const eng=track&&track.synth_engine;const instrumentId=eng&&eng.plugin_id||track&&track.instrumentId||'';const notes=item&&item.notes||[];const nk=notes.map(function(n){return(n.pitch||60)+':'+(n.start_beat||0)+':'+(n.duration_beats||1)+':'+(n.velocity!=null?n.velocity:0.8);}).join(',');return[item&&item.id?item.id:instrumentId+':'+nk,instrumentId,eng&&eng.preset_id||'',eng&&eng.preset_path||'',bpm].join('|')+'|'+nk;}catch(e){return'';}};const _renderNativeVstItem=(track,item,bpm)=>{try{const eng=track&&track.synth_engine;const instrumentId=eng&&eng.plugin_id||track&&track.instrumentId;if(!instrumentId||!isVstTrackEngine(eng))return Promise.resolve(null);const notes=item&&item.notes||[];if(!notes.length)return Promise.resolve(null);return window.SonicAPI.midiRender({instrument_id:instrumentId,notes:notes.map(function(n){return{pitch:n.pitch||60,start_beat:n.start_beat||0,duration_beats:n.duration_beats||1,velocity:n.velocity!=null?n.velocity:0.8};}),bpm:parseFloat(bpm)||120,preset_id:eng&&eng.preset_id||undefined,preset_path:eng&&eng.preset_path||undefined,preset_data:eng&&eng.preset_data||undefined}).then(function(res){if(!res||!res.success||!res.url)return null;return API_BASE_URL+res.url;}).catch(function(){return null;});}catch(e){return Promise.resolve(null);}};// Dedupe in-flight render: play trong lúc pre-render đang chạy → chờ cùng promise,
// không bắn duplicate request (tránh queue sau RENDER_LOCK + render 2 lần).
const _nativeVstItemPending={};const ensureRenderNativeVstItem=(track,item,bpm)=>{const key=_nativeVstItemKey(track,item,bpm);if(!key)return _renderNativeVstItem(track,item,bpm);if(_nativeVstItemUrls[key])return Promise.resolve(_nativeVstItemUrls[key]);if(_nativeVstItemPending[key])return _nativeVstItemPending[key];const p=_renderNativeVstItem(track,item,bpm).then(function(url){if(url)_nativeVstItemUrls[key]=url;return url;}).then(function(url){delete _nativeVstItemPending[key];return url;});_nativeVstItemPending[key]=p;return p;};let _prerenderNativeVstTimer=null;const prerenderNativeVstItems=(tracks,bpm)=>{try{if(!isStandaloneSf())return;if(_prerenderNativeVstTimer){clearTimeout(_prerenderNativeVstTimer);_prerenderNativeVstTimer=null;}_prerenderNativeVstTimer=setTimeout(function(){_prerenderNativeVstTimer=null;const seen={};(tracks||[]).forEach(function(track){const nativeVst=isStandaloneSf()&&isVstTrackEngine(track&&track.synth_engine)&&!(window.SonicMidiRouter&&window.SonicMidiRouter.isBridgeActive());if(!nativeVst)return;(track.midiItems||[]).forEach(function(item){const key=_nativeVstItemKey(track,item,bpm);if(!key||seen[key]||_nativeVstItemUrls[key])return;seen[key]=true;ensureRenderNativeVstItem(track,item,bpm).then(function(){});});});},800);}catch(e){}};// ── Native VSTi item render (standalone, transport) ────────────────────────
// Render TOÀN BỘ MIDI item bằng pedalboard (VST3) → decode AudioBuffer → // Render TOÀN BỘ MIDI item bằng pedalboard (VST3) → decode AudioBuffer →
// schedule giống scheduleNativeSfItem. opts giống hệt nativeSf. // schedule giống scheduleNativeSfItem. opts giống hệt nativeSf.
const scheduleNativeVstItem=(track,item,offsetTime,context,destNode,bpm,opts)=>{try{const eng=track&&track.synth_engine;const instrumentId=eng&&eng.plugin_id||track&&track.instrumentId;if(!instrumentId||!isVstTrackEngine(eng))return;const notes=item&&item.notes||[];if(!notes.length)return;const secPerBeat=60.0/(parseInt(bpm)||120);const baseOffsetSec=opts&&opts.baseOffsetSec||0;const itemStartAbs=baseOffsetSec+(item.startTime||0);let itemEndAbs=itemStartAbs+0.05;notes.forEach(function(n){const end=itemStartAbs+((n.start_beat||0)+(n.duration_beats||1))*secPerBeat;if(end>itemEndAbs)itemEndAbs=end;});window.SonicAPI.midiRender({instrument_id:instrumentId,notes:notes.map(function(n){return{pitch:n.pitch||60,start_beat:n.start_beat||0,duration_beats:n.duration_beats||1,velocity:n.velocity!=null?n.velocity:0.8};}),bpm:parseFloat(bpm)||120,preset_id:eng&&eng.preset_id||undefined,preset_path:eng&&eng.preset_path||undefined,preset_data:eng&&eng.preset_data||undefined}).then(function(res){if(!res||!res.success||!res.url)return;fetch(API_BASE_URL+res.url).then(function(r){return r.arrayBuffer();}).then(function(buf){context.decodeAudioData(buf,function(audioBuf){try{if(opts&&typeof opts.isActive==='function'&&!opts.isActive())return;const src=context.createBufferSource();src.buffer=audioBuf;src.connect(destNode);if(offsetTime<itemStartAbs){src.start(context.currentTime+(itemStartAbs-offsetTime),0);}else if(offsetTime<itemEndAbs){src.start(context.currentTime,Math.min(offsetTime-itemStartAbs,Math.max(0,audioBuf.duration-0.05)));}else{return;}if(opts&&opts.limitSec&&opts.limitSec<itemStartAbs+audioBuf.duration){src.stop(context.currentTime+Math.max(0.02,opts.limitSec-Math.max(offsetTime,itemStartAbs)));}if(opts&&opts.sources&&opts.sources.push)opts.sources.push(src);}catch(e){console.warn('[NativeVST] schedule decode error:',e);}},function(){});}).catch(function(){});}).catch(function(){});}catch(e){console.warn('[NativeVST] scheduleNativeVstItem error:',e);}};// Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project) const scheduleNativeVstItem=(track,item,offsetTime,context,destNode,bpm,opts)=>{try{const eng=track&&track.synth_engine;const instrumentId=eng&&eng.plugin_id||track&&track.instrumentId;if(!instrumentId||!isVstTrackEngine(eng))return;const notes=item&&item.notes||[];if(!notes.length)return;const secPerBeat=60.0/(parseInt(bpm)||120);const baseOffsetSec=opts&&opts.baseOffsetSec||0;const itemStartAbs=baseOffsetSec+(item.startTime||0);let itemEndAbs=itemStartAbs+0.05;notes.forEach(function(n){const end=itemStartAbs+((n.start_beat||0)+(n.duration_beats||1))*secPerBeat;if(end>itemEndAbs)itemEndAbs=end;});const key=_nativeVstItemKey(track,item,bpm);const scheduleUrl=function(url){if(!url)return;fetch(url).then(function(r){return r.arrayBuffer();}).then(function(buf){context.decodeAudioData(buf,function(audioBuf){try{if(opts&&typeof opts.isActive==='function'&&!opts.isActive())return;const src=context.createBufferSource();src.buffer=audioBuf;src.connect(destNode);if(offsetTime<itemStartAbs){src.start(context.currentTime+(itemStartAbs-offsetTime),0);}else if(offsetTime<itemEndAbs){src.start(context.currentTime,Math.min(offsetTime-itemStartAbs,Math.max(0,audioBuf.duration-0.05)));}else{return;}if(opts&&opts.limitSec&&opts.limitSec<itemStartAbs+audioBuf.duration){src.stop(context.currentTime+Math.max(0.02,opts.limitSec-Math.max(offsetTime,itemStartAbs)));}if(opts&&opts.sources&&opts.sources.push)opts.sources.push(src);}catch(e){console.warn('[NativeVST] schedule decode error:',e);}},function(){});}).catch(function(){});};if(key&&_nativeVstItemUrls[key]){scheduleUrl(_nativeVstItemUrls[key]);return;}ensureRenderNativeVstItem(track,item,bpm).then(function(url){if(!url)return;scheduleUrl(url);});}catch(e){console.warn('[NativeVST] scheduleNativeVstItem error:',e);}};// Handle ?sfs=<encoded> from double-clicking a .sfs file (opens domain -> loads project)
(function handleSfsDeepLink(){try{const params=new URLSearchParams(window.location.search);const sfsParam=params.get('sfs');if(!sfsParam)return;const decoded=JSON.parse(decodeURIComponent(sfsParam));window.__pendingSfsProject=decoded;// consumed after auth in App (function handleSfsDeepLink(){try{const params=new URLSearchParams(window.location.search);const sfsParam=params.get('sfs');if(!sfsParam)return;const decoded=JSON.parse(decodeURIComponent(sfsParam));window.__pendingSfsProject=decoded;// consumed after auth in App
if(window.history.replaceState){window.history.replaceState({},document.title,window.location.pathname);}}catch(e){window.__pendingSfsProject=null;}})();// Storage for server-side file IDs mapped to track IDs if(window.history.replaceState){window.history.replaceState({},document.title,window.location.pathname);}}catch(e){window.__pendingSfsProject=null;}})();// Storage for server-side file IDs mapped to track IDs
let serverFileIdMap={};let audioCtx;let masterBus=null;// { input, compressor, analyser, output, masteringActive, dryInput, dryOutput } let serverFileIdMap={};let audioCtx;let masterBus=null;// { input, compressor, analyser, output, masteringActive, dryInput, dryOutput }
@@ -1051,10 +1055,10 @@ const sectionInnerTracks=[];updatedTracks.forEach(function(ut){(ut.sections||[])
const prof=currentUser||function(){try{return JSON.parse(localStorage.getItem('sonic_user')||'null');}catch(e){return null;}}();if(prof&&window.SonicAPI&&window.SonicAPI.listCloudProjects){try{const cloudList=await window.SonicAPI.listCloudProjects();if(cloudList&&cloudList.length>0){const latest=cloudList[0];// backend ORDER BY updated_at DESC const prof=currentUser||function(){try{return JSON.parse(localStorage.getItem('sonic_user')||'null');}catch(e){return null;}}();if(prof&&window.SonicAPI&&window.SonicAPI.listCloudProjects){try{const cloudList=await window.SonicAPI.listCloudProjects();if(cloudList&&cloudList.length>0){const latest=cloudList[0];// backend ORDER BY updated_at DESC
lastId=latest.id;lastName=latest.name||lastName;const proj=await window.SonicAPI.getCloudProject(lastId);if(proj&&proj.data_json)parsed=JSON.parse(proj.data_json);}}catch(e){console.warn('restore cloud fallback error:',e);}}}else{try{if(lastId.startsWith('local_')){var localData=localStorage.getItem('sonic_local_project_data');if(localData)parsed=JSON.parse(localData);}else{var proj=await window.SonicAPI.getCloudProject(lastId);if(proj)parsed=JSON.parse(proj.data_json);}}catch(e){console.warn('restore load error:',e);}}try{if(!parsed)return;var restoredBpm=bpm;var restoredTracks=[];var restoredSessionTabs=[];var restoredSubTabs=[];if(parsed.main_session){var result=deserializeProjectFromSchema(parsed);restoredTracks=result.tracks;restoredBpm=result.bpm;restoredSessionTabs=result.sessionTabs;restoredSubTabs=result.subTabs;// Bỏ qua zoom ≤ 1 (data cũ lưu 1.0 — lỗi __currentZoom của Media lastId=latest.id;lastName=latest.name||lastName;const proj=await window.SonicAPI.getCloudProject(lastId);if(proj&&proj.data_json)parsed=JSON.parse(proj.data_json);}}catch(e){console.warn('restore cloud fallback error:',e);}}}else{try{if(lastId.startsWith('local_')){var localData=localStorage.getItem('sonic_local_project_data');if(localData)parsed=JSON.parse(localData);}else{var proj=await window.SonicAPI.getCloudProject(lastId);if(proj)parsed=JSON.parse(proj.data_json);}}catch(e){console.warn('restore load error:',e);}}try{if(!parsed)return;var restoredBpm=bpm;var restoredTracks=[];var restoredSessionTabs=[];var restoredSubTabs=[];if(parsed.main_session){var result=deserializeProjectFromSchema(parsed);restoredTracks=result.tracks;restoredBpm=result.bpm;restoredSessionTabs=result.sessionTabs;restoredSubTabs=result.subTabs;// Bỏ qua zoom ≤ 1 (data cũ lưu 1.0 — lỗi __currentZoom của Media
// Explorer) — áp dụng mới giữ zoom hiện tại/localStorage — hết thu nhỏ. // 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);}else{restoredTracks=(parsed.tracks||[]).map(function(t){var rest=Object.assign({},t);delete rest.height;return Object.assign({},rest,{buffer:null,channelInfo:t.channelInfo||null,clips:t.clips||[],serverFileId:t.serverFileId||null});});}setTracks(restoredTracks);loadAudioBuffersForTracks(restoredTracks).catch(function(err){console.warn('loadAudioBuffersForTracks error:',err);});trackMidiChannelsRef.current={};preloadTrackInstruments(restoredTracks).catch(function(err){console.warn('preloadTrackInstruments error:',err);});setBpm(restoredBpm.toString());setSelectedTrackId(restoredTracks[0]?.id||'1');setProjectName(lastName);setCurrentProjectId(lastId);localStorage.setItem('sonic_project_id',lastId);setSessionTabs(restoredSessionTabs);setSubTabs(restoredSubTabs);// DIAG section-tab block: log tracks + sections của section tab sau restore if(result.zoom>1&&setZoom)setZoom(result.zoom);if(result.masteringSettings)setMasteringSettings(result.masteringSettings);}else{restoredTracks=(parsed.tracks||[]).map(function(t){var rest=Object.assign({},t);delete rest.height;return Object.assign({},rest,{buffer:null,channelInfo:t.channelInfo||null,clips:t.clips||[],serverFileId:t.serverFileId||null});});}setTracks(restoredTracks);loadAudioBuffersForTracks(restoredTracks).catch(function(err){console.warn('loadAudioBuffersForTracks error:',err);});trackMidiChannelsRef.current={};preloadTrackInstruments(restoredTracks).catch(function(err){console.warn('preloadTrackInstruments error:',err);});prerenderNativeVstItems(restoredTracks,restoredBpm);setBpm(restoredBpm.toString());setSelectedTrackId(restoredTracks[0]?.id||'1');setProjectName(lastName);setCurrentProjectId(lastId);localStorage.setItem('sonic_project_id',lastId);setSessionTabs(restoredSessionTabs);setSubTabs(restoredSubTabs);// DIAG section-tab block: log tracks + sections của section tab sau restore
try{(restoredSessionTabs||[]).forEach(function(st){var secCount=0,midiCount=0,clipCount=0;(st.tracks||[]).forEach(function(tr){secCount+=(tr.sections||[]).length;midiCount+=(tr.midiItems||[]).length;clipCount+=(tr.clips||[]).length;});console.log('[Restore] sessionTab',st.id,'sectionId',st.sectionId,'tracks',(st.tracks||[]).length,'sections',secCount,'midi',midiCount,'clips',clipCount);});}catch(e){}var restoredItemCount=0;restoredTracks.forEach(function(rt){if(rt.clips)restoredItemCount+=rt.clips.length;if(rt.midiItems)restoredItemCount+=rt.midiItems.length;if(rt.sections)restoredItemCount+=rt.sections.length;});showToast('Đã khôi phục dự án "'+lastName+'" ('+restoredItemCount+' items).','info');}catch(e){console.warn('restoreLastSessionProject failed:',e);// Stale session id (project deleted / DB reset): clear it so the error try{(restoredSessionTabs||[]).forEach(function(st){var secCount=0,midiCount=0,clipCount=0;(st.tracks||[]).forEach(function(tr){secCount+=(tr.sections||[]).length;midiCount+=(tr.midiItems||[]).length;clipCount+=(tr.clips||[]).length;});console.log('[Restore] sessionTab',st.id,'sectionId',st.sectionId,'tracks',(st.tracks||[]).length,'sections',secCount,'midi',midiCount,'clips',clipCount);});}catch(e){}var restoredItemCount=0;restoredTracks.forEach(function(rt){if(rt.clips)restoredItemCount+=rt.clips.length;if(rt.midiItems)restoredItemCount+=rt.midiItems.length;if(rt.sections)restoredItemCount+=rt.sections.length;});showToast('Đã khôi phục dự án "'+lastName+'" ('+restoredItemCount+' items).','info');}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. // does not repeat on every page load.
localStorage.removeItem('sonic_project_id');localStorage.removeItem('sonic_project_name');}};useEffect(()=>{const checkAuthStatus=async()=>{const savedToken=localStorage.getItem('sonic_token');if(!savedToken){setIsMandatoryLogin(true);setAuthMode('login');setAuthModalOpen(true);return;}try{const profile=await window.SonicAPI.getProfile();setCurrentUser(profile);localStorage.setItem('sonic_user',JSON.stringify(profile));if(profile.must_change_password){setIsMandatoryLogin(true);setAuthMode('force_change');setAuthModalOpen(true);}else{setIsMandatoryLogin(false);setAuthModalOpen(false);restoreLastSessionProject();}}catch(err){const cached=localStorage.getItem('sonic_user');if(cached){try{setCurrentUser(JSON.parse(cached));}catch(_){}setIsMandatoryLogin(false);setAuthModalOpen(false);restoreLastSessionProject();}else{localStorage.removeItem('sonic_token');localStorage.removeItem('sonic_user');setCurrentUser(null);setIsMandatoryLogin(true);setAuthMode('login');setAuthModalOpen(true);}}};checkAuthStatus();},[]);const loadPendingSfsProject=()=>{const proj=window.__pendingSfsProject;if(!proj)return;try{let restoredTracks=[];let restoredBpm=bpm;let restoredSessionTabs=[];let restoredSubTabs=[];if(proj.main_session){const result=deserializeProjectFromSchema(proj);restoredTracks=result.tracks;restoredBpm=result.bpm;restoredSessionTabs=result.sessionTabs;restoredSubTabs=result.subTabs;if(result.masteringSettings){setMasteringSettings(result.masteringSettings);}else{setMasteringSettings({masterConnected:false,activeModule:'eq',eqActive:true,imagerActive:true,maximizerActive:true,eqLowGain:1.5,eqMid1Gain:-1.0,eqMid2Gain:2.0,eqHighGain:1.8,w1:0,w2:115,w3:135,w4:150,imagerScale:'v2',maxGain:5.4,maxUpward:2.0,maxSoftClip:15,maxTransient:25,ceiling:-0.1,isBypassed:false,chain:DEFAULT_MASTER_CHAIN.map(m=>({...m})),compActive:false,compThreshold:-16,compRatio:3,compMakeup:0,limActive:false,limThreshold:-1.0,excActive:false,excDrive:40,rebalActive:false,rebalMid:0,rebalSide:0,carlaBridgeActive:false,carlaBridge:{plugin:'',plugin_path:'',connected:false}});}}else{restoredTracks=(proj.tracks||[]).map(t=>{const{height:_h,...rest}=t;return{...rest,buffer:null,channelInfo:t.channelInfo||null,clips:t.clips||[],serverFileId:t.serverFileId||null};});}if(restoredTracks.length>0){setTracks(restoredTracks);setBpm(restoredBpm.toString());if(restoredSessionTabs.length>0){setSessionTabs(restoredSessionTabs);}if(restoredSubTabs.length>0){setSubTabs(restoredSubTabs);}showToast(`Đã tải dự án "${proj.metadata?.title||proj.name||'Dự án mới'}" từ liên kết .sfs thành công!`,"success");}}catch(e){showToast("Lỗi tải dự án từ .sfs","error");}finally{window.__pendingSfsProject=null;}};const handleAuthSuccess=user=>{setCurrentUser(user);if(user.must_change_password){setIsMandatoryLogin(true);setAuthMode('force_change');setAuthModalOpen(true);}else{setIsMandatoryLogin(false);setAuthModalOpen(false);restoreLastSessionProject();const loadPrefs=p=>{if(!p)return;if(p.showAIPanel!==undefined)setShowAIPanel(p.showAIPanel);if(p.showExportPanel!==undefined)setShowExportPanel(p.showExportPanel);if(p.showSelectionPanel!==undefined)setShowSelectionPanel(p.showSelectionPanel);if(p.showPythonToolsPanel!==undefined)setShowPythonToolsPanel(p.showPythonToolsPanel);if(p.showMediaExplorer!==undefined)setShowMediaExplorer(p.showMediaExplorer);if(p.showFxRack!==undefined)setShowFxRack(p.showFxRack);if(p.showMidiEvents!==undefined)setShowMidiEvents(p.showMidiEvents);if(p.panelPositions)setPanelPositions(p.panelPositions);if(p.rightSidebarWidth)setRightSidebarWidth(p.rightSidebarWidth);if(p.mediaExplorerHeight)setMediaExplorerHeight(p.mediaExplorerHeight);if(p.selectedProviderId)setSelectedProviderId(p.selectedProviderId);};(async()=>{try{const data=await window.SonicAPI.getPreferences();if(data&&data.preferences)loadPrefs(data.preferences);else{const cached=localStorage.getItem('sonic_preferences');if(cached)loadPrefs(JSON.parse(cached));}}catch(e){const cached=localStorage.getItem('sonic_preferences');if(cached)loadPrefs(JSON.parse(cached));}try{const data=await window.SonicAPI.getAIConfigs();if(data&&data.providers){setAiProviders(data.providers);const active=data.providers.find(p=>p.is_active)||data.providers[0];if(active)setSelectedProviderId(active.id);}}catch(e){}try{window.SonicAPI.getSoundfontCatalog().then(cat=>{window.__soundfontCatalog=cat;}).catch(()=>{});}catch(e){}// Re-fetch instrument data after auth (useEffect on mount runs before token is set) localStorage.removeItem('sonic_project_id');localStorage.removeItem('sonic_project_name');}};useEffect(()=>{const checkAuthStatus=async()=>{const savedToken=localStorage.getItem('sonic_token');if(!savedToken){setIsMandatoryLogin(true);setAuthMode('login');setAuthModalOpen(true);return;}try{const profile=await window.SonicAPI.getProfile();setCurrentUser(profile);localStorage.setItem('sonic_user',JSON.stringify(profile));if(profile.must_change_password){setIsMandatoryLogin(true);setAuthMode('force_change');setAuthModalOpen(true);}else{setIsMandatoryLogin(false);setAuthModalOpen(false);restoreLastSessionProject();}}catch(err){const cached=localStorage.getItem('sonic_user');if(cached){try{setCurrentUser(JSON.parse(cached));}catch(_){}setIsMandatoryLogin(false);setAuthModalOpen(false);restoreLastSessionProject();}else{localStorage.removeItem('sonic_token');localStorage.removeItem('sonic_user');setCurrentUser(null);setIsMandatoryLogin(true);setAuthMode('login');setAuthModalOpen(true);}}};checkAuthStatus();},[]);const loadPendingSfsProject=()=>{const proj=window.__pendingSfsProject;if(!proj)return;try{let restoredTracks=[];let restoredBpm=bpm;let restoredSessionTabs=[];let restoredSubTabs=[];if(proj.main_session){const result=deserializeProjectFromSchema(proj);restoredTracks=result.tracks;restoredBpm=result.bpm;restoredSessionTabs=result.sessionTabs;restoredSubTabs=result.subTabs;if(result.masteringSettings){setMasteringSettings(result.masteringSettings);}else{setMasteringSettings({masterConnected:false,activeModule:'eq',eqActive:true,imagerActive:true,maximizerActive:true,eqLowGain:1.5,eqMid1Gain:-1.0,eqMid2Gain:2.0,eqHighGain:1.8,w1:0,w2:115,w3:135,w4:150,imagerScale:'v2',maxGain:5.4,maxUpward:2.0,maxSoftClip:15,maxTransient:25,ceiling:-0.1,isBypassed:false,chain:DEFAULT_MASTER_CHAIN.map(m=>({...m})),compActive:false,compThreshold:-16,compRatio:3,compMakeup:0,limActive:false,limThreshold:-1.0,excActive:false,excDrive:40,rebalActive:false,rebalMid:0,rebalSide:0,carlaBridgeActive:false,carlaBridge:{plugin:'',plugin_path:'',connected:false}});}}else{restoredTracks=(proj.tracks||[]).map(t=>{const{height:_h,...rest}=t;return{...rest,buffer:null,channelInfo:t.channelInfo||null,clips:t.clips||[],serverFileId:t.serverFileId||null};});}if(restoredTracks.length>0){setTracks(restoredTracks);setBpm(restoredBpm.toString());prerenderNativeVstItems(restoredTracks,restoredBpm);if(restoredSessionTabs.length>0){setSessionTabs(restoredSessionTabs);}if(restoredSubTabs.length>0){setSubTabs(restoredSubTabs);}showToast(`Đã tải dự án "${proj.metadata?.title||proj.name||'Dự án mới'}" từ liên kết .sfs thành công!`,"success");}}catch(e){showToast("Lỗi tải dự án từ .sfs","error");}finally{window.__pendingSfsProject=null;}};const handleAuthSuccess=user=>{setCurrentUser(user);if(user.must_change_password){setIsMandatoryLogin(true);setAuthMode('force_change');setAuthModalOpen(true);}else{setIsMandatoryLogin(false);setAuthModalOpen(false);restoreLastSessionProject();const loadPrefs=p=>{if(!p)return;if(p.showAIPanel!==undefined)setShowAIPanel(p.showAIPanel);if(p.showExportPanel!==undefined)setShowExportPanel(p.showExportPanel);if(p.showSelectionPanel!==undefined)setShowSelectionPanel(p.showSelectionPanel);if(p.showPythonToolsPanel!==undefined)setShowPythonToolsPanel(p.showPythonToolsPanel);if(p.showMediaExplorer!==undefined)setShowMediaExplorer(p.showMediaExplorer);if(p.showFxRack!==undefined)setShowFxRack(p.showFxRack);if(p.showMidiEvents!==undefined)setShowMidiEvents(p.showMidiEvents);if(p.panelPositions)setPanelPositions(p.panelPositions);if(p.rightSidebarWidth)setRightSidebarWidth(p.rightSidebarWidth);if(p.mediaExplorerHeight)setMediaExplorerHeight(p.mediaExplorerHeight);if(p.selectedProviderId)setSelectedProviderId(p.selectedProviderId);};(async()=>{try{const data=await window.SonicAPI.getPreferences();if(data&&data.preferences)loadPrefs(data.preferences);else{const cached=localStorage.getItem('sonic_preferences');if(cached)loadPrefs(JSON.parse(cached));}}catch(e){const cached=localStorage.getItem('sonic_preferences');if(cached)loadPrefs(JSON.parse(cached));}try{const data=await window.SonicAPI.getAIConfigs();if(data&&data.providers){setAiProviders(data.providers);const active=data.providers.find(p=>p.is_active)||data.providers[0];if(active)setSelectedProviderId(active.id);}}catch(e){}try{window.SonicAPI.getSoundfontCatalog().then(cat=>{window.__soundfontCatalog=cat;}).catch(()=>{});}catch(e){}// Re-fetch instrument data after auth (useEffect on mount runs before token is set)
try{window.SonicAPI.listPlugins().then(async data=>{try{const catResp=(await window.SonicAPI.getSoundfontCatalog?.())??(await fetch('/api/v1/plugins/soundfonts/catalog').then(r=>r.json()));const catalog=catResp.full_catalog||{};data.soundfonts=(data.soundfonts||[]).map(sf=>{const sfId=sf.id.replace('sf_','');const catEntry=catalog[sfId.toLowerCase()]||catalog[sfId];if(catEntry&&catEntry.instruments)return{...sf,presets:catEntry.instruments};return sf;});}catch(e){console.warn('Catalog fetch error:',e);}setInstrumentSelectorData(data);}).catch(()=>{});}catch(e){}// ── Khôi phục dự án đang làm dở (temp autosave) khi load lại app ── try{window.SonicAPI.listPlugins().then(async data=>{try{const catResp=(await window.SonicAPI.getSoundfontCatalog?.())??(await fetch('/api/v1/plugins/soundfonts/catalog').then(r=>r.json()));const catalog=catResp.full_catalog||{};data.soundfonts=(data.soundfonts||[]).map(sf=>{const sfId=sf.id.replace('sf_','');const catEntry=catalog[sfId.toLowerCase()]||catalog[sfId];if(catEntry&&catEntry.instruments)return{...sf,presets:catEntry.instruments};return sf;});}catch(e){console.warn('Catalog fetch error:',e);}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 // 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 // dự án đang làm dở." Chỉ restore khi app khởi động với project TRỐNG
+1 -1
View File
@@ -50,7 +50,7 @@
<script src="/static/js/services/midiExtractor.js?v=202607281052"></script> <script src="/static/js/services/midiExtractor.js?v=202607281052"></script>
<script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script> <script src="/static/js/services/promptTemplateManager.js?v=202607281039"></script>
<script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script> <script src="/static/js/services/undoRedoEngine.js?v=202607290941"></script>
<script src="/static/js/app.precompiled.js?v=202608141031" defer></script> <script src="/static/js/app.precompiled.js?v=202608152100" defer></script>
<link rel="stylesheet" href="/static/css/styles.css?v=202607271016"> <link rel="stylesheet" href="/static/css/styles.css?v=202607271016">
<style> <style>
:root { :root {