fix: 3 bug standalone — soundfont preview/play, Carla bridge play item, realtime sync LAN/Tauri

Bug1: dispatcher CREATE_MIDI_ITEM track_id number vs state string -> String(); play+preview native soundfont verified.
Bug2: Carla play item — chờ OSC ready (poll carla-status, queue nốt khi cold start), chống spawn trùng (carla-status dò port bind, open-in-carla skip khi đã chạy).
Bug3: realtime sync — GET /temp/revision (updated_at+client_id), poll 3s, apply qua deserializeProjectFromSchema; hash-skip autosave + client_id chống ping-pong.
This commit is contained in:
2026-08-10 22:10:12 +07:00
parent d31e226f00
commit aa1cd8d5de
7 changed files with 238 additions and 32 deletions
+42 -4
View File
@@ -563,6 +563,25 @@ def _carla_osc_port() -> int:
return 22752 return 22752
def _carla_osc_ready() -> bool:
"""Carla OSC engine đang lắng nghe cổng UDP chưa — thử bind socket vào đúng
cổng. Bind THÀNH CÔNG → cổng trống (Carla chưa mở / OSC chưa sẵn sàng);
EADDRINUSE → Carla đang giữ cổng (kể cả mở thủ công ngoài app, hoặc app
spawn đang boot tới giai đoạn OSC). KHÔNG set SO_REUSEADDR (Linux/Win cho
phép 2 UDP socket cùng port → mất tác dụng phát hiện)."""
try:
import socket
port = _carla_osc_port()
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("127.0.0.1", port))
s.close()
return False
except OSError:
return True
except Exception:
return False
def _send_carla_osc(event: str, note: int, velocity: int, channel: int) -> bool: def _send_carla_osc(event: str, note: int, velocity: int, channel: int) -> bool:
"""Gửi OSC UDP tới `/Carla/0/{event}` (plugin đầu tiên = plugin auto-load). """Gửi OSC UDP tới `/Carla/0/{event}` (plugin đầu tiên = plugin auto-load).
@@ -626,6 +645,20 @@ async def open_in_carla(req: OpenInCarlaRequest, current_user: dict = Depends(ge
carxs_path = "" carxs_path = ""
if plugin_path: if plugin_path:
carxs_path = _write_carla_project(req.plugin_name or os.path.basename(plugin_path), plugin_path) carxs_path = _write_carla_project(req.plugin_name or os.path.basename(plugin_path), plugin_path)
# ⚠️ FIX (Bug 2): Carla ĐÃ chạy (mở thủ công / app spawn từ trước, hoặc
# đang boot) → KHÔNG spawn bản 2 (trùng OSC port 22752 + audio → bridge
# hỏng; Carla là single-instance). Trả already_running — frontend poll
# carla-status chờ OSC ready rồi mới gửi note.
if _carla_osc_ready() or len(_CARLA_PROCESSES) > 0:
return {
"success": True,
"started": False,
"already_running": True,
"carla_path": carla,
"plugin_path": plugin_path,
"project_file": carxs_path,
"cmd": [],
}
cmd = [carla] cmd = [carla]
if carxs_path: if carxs_path:
cmd.append(carxs_path) cmd.append(carxs_path)
@@ -683,15 +716,18 @@ def _send_carla_all_notes_off() -> bool:
@router.get("/carla-status") @router.get("/carla-status")
async def carla_status(): async def carla_status():
"""Kiểm tra Carla bridge còn sống không (do app spawn) + cổng OSC đang dùng. """Kiểm tra Carla bridge còn sống không (OSC engine đang nghe cổng UDP —
phát hiện CẢ Carla mở thủ công ngoài app, không chỉ app-spawn; chính xác
hơn _CARLA_PROCESSES vì OSC bind khi Carla boot xong engine).
Frontend dùng để quyết định: route MIDI item EXCLUSIVE qua Carla (chỉ khi Frontend dùng để quyết định: route MIDI item EXCLUSIVE qua Carla (chỉ khi
Carla đang chạy) hay fallback FluidSynth (luôn có âm) — tránh câm toàn Carla đang chạy) hay fallback FluidSynth (luôn có âm) — tránh câm toàn
phần khi Carla bị đóng.""" phần khi Carla bị đóng. running=OSC ready → gửi note là tới được Carla."""
_prune_carla_processes() _prune_carla_processes()
return { return {
"success": True, "success": True,
"running": len(_CARLA_PROCESSES) > 0, "running": _carla_osc_ready(),
"app_spawned": len(_CARLA_PROCESSES) > 0,
"osc_port": _carla_osc_port(), "osc_port": _carla_osc_port(),
} }
@@ -1118,7 +1154,9 @@ def find_carla_local() -> str:
def _carla_bridge_running() -> bool: def _carla_bridge_running() -> bool:
try: try:
_prune_carla_processes() _prune_carla_processes()
return len(_CARLA_PROCESSES) > 0 # OSC ready (mở thủ công / app spawn đã boot) HOẶC tiến trình app-spawn
# còn sống (đang boot) — preview chấp nhận gửi sớm một chút.
return _carla_osc_ready() or len(_CARLA_PROCESSES) > 0
except Exception: except Exception:
return False return False
+36
View File
@@ -136,6 +136,7 @@ class SaveProjectRequest(BaseModel):
class SaveTempProjectRequest(BaseModel): class SaveTempProjectRequest(BaseModel):
data_json: str data_json: str
client_id: Optional[str] = None # client ghi bản này (LAN browser / Tauri UI) — chống ping-pong sync
def get_optional_user(authorization: Optional[str] = Header(None)) -> Optional[dict]: def get_optional_user(authorization: Optional[str] = Header(None)) -> Optional[dict]:
if authorization and authorization.startswith("Bearer "): if authorization and authorization.startswith("Bearer "):
@@ -172,6 +173,10 @@ async def save_temp_project(req: SaveTempProjectRequest, current_user: Optional[
os.makedirs(temp_dir, exist_ok=True) os.makedirs(temp_dir, exist_ok=True)
with open(os.path.join(temp_dir, "autosave.json"), "w", encoding="utf-8") as f: with open(os.path.join(temp_dir, "autosave.json"), "w", encoding="utf-8") as f:
json.dump({"user_id": user_id, "updated_at": now, "data_json": validated_data_json}, f, ensure_ascii=False) json.dump({"user_id": user_id, "updated_at": now, "data_json": validated_data_json}, f, ensure_ascii=False)
# Revision sidecar (nhỏ) — realtime sync LAN/Tauri đọc updated_at +
# client_id KHÔNG cần parse data_json (có thể MB).
with open(os.path.join(temp_dir, f"revision_{user_id}.json"), "w", encoding="utf-8") as f:
json.dump({"client_id": req.client_id or "", "updated_at": now}, f, ensure_ascii=False)
except Exception: except Exception:
pass pass
return {"message": "Đã lưu dự án tạm tự động", "updated_at": now} return {"message": "Đã lưu dự án tạm tự động", "updated_at": now}
@@ -212,6 +217,37 @@ async def get_temp_project(current_user: Optional[dict] = Depends(get_optional_u
pass pass
return {"has_temp": False} return {"has_temp": False}
@router.get("/temp/revision")
async def temp_project_revision(current_user: Optional[dict] = Depends(get_optional_user)):
"""Revision nhẹ của bản temp: updated_at + client_id — frontend poll 2-3s
để realtime sync giữa LAN browser Tauri standalone (không tải full
data_json mỗi lần). client_id = client ghi bản cuối client khác bỏ qua
bản do CHÍNH ghi (chống ping-pong)."""
user_id = current_user["user_id"] if current_user else "anonymous"
client_id = ""
updated_at = 0.0
try:
from app.config import settings as _st
rev_path = os.path.join(_st.STORAGE_DIR, "temp", f"revision_{user_id}.json")
if os.path.isfile(rev_path):
with open(rev_path, "r", encoding="utf-8") as f:
meta = json.load(f)
client_id = meta.get("client_id", "") or ""
updated_at = float(meta.get("updated_at", 0) or 0)
except Exception:
pass
if not updated_at:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT updated_at FROM projects WHERE id = ? AND is_temp = 1", (f"temp_{user_id}",))
row = cursor.fetchone()
conn.close()
if row:
updated_at = float(row["updated_at"] or 0)
return {"updated_at": updated_at, "client_id": client_id}
def _os_projects_dir() -> str: def _os_projects_dir() -> str:
"""Thư mục dự án trên hệ điều hành (Ctrl-S desktop): """Thư mục dự án trên hệ điều hành (Ctrl-S desktop):
Windows Documents/SonicForgeDAW/Projects (fallback USERPROFILE); Windows Documents/SonicForgeDAW/Projects (fallback USERPROFILE);
+109 -14
View File
@@ -89,6 +89,23 @@ const ensureSonicInstrument = (ctx) => {
// track dùng VSTi + có Carla local. Dùng chung cho mi đưng playback // 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 // (main timeline, local loop, piano roll, ghost notes) đ MIDI item PHI play
// qua Carla bridge khi VSTi đưc loaded. // qua Carla bridge khi VSTi đưc loaded.
const fireCarlaNote = (ch, pitch, vel, delay, durMs) => {
setTimeout(function () { window.SonicCarlaMidi.noteOn(ch, pitch, vel); }, delay);
setTimeout(function () { window.SonicCarlaMidi.noteOff(ch, pitch); }, delay + durMs + 30);
};
// FIX (Bug 2): Carla m ASYNC lúc play (ensureCarlaForPlayback) nt gi
// trưc khi OSC engine bind cng b mt item câm dù keybed kêu. Nt khi
// chưa ready đưc nhét queue, flush khi bridge OSC ready (gi đúng th t).
const flushCarlaNoteQueue = () => {
const q = window.__carlaNoteQueue || [];
if (!q.length) return;
window.__carlaNoteQueue = [];
q.forEach(item => {
const remaining = item.delay - (Date.now() - item.scheduledAt);
if (remaining <= 0) fireCarlaNote(item.ch, item.pitch, item.vel, 0, item.durMs);
else fireCarlaNote(item.ch, item.pitch, item.vel, remaining, item.durMs);
});
};
const scheduleCarlaNote = (synthEngine, channel, pitch, velocity, startWallTime, durMs) => { const scheduleCarlaNote = (synthEngine, channel, pitch, velocity, startWallTime, durMs) => {
try { try {
if (!window.SonicCarlaMidi || !window.SonicCarlaMidi.shouldRoutePlayback(synthEngine)) return; if (!window.SonicCarlaMidi || !window.SonicCarlaMidi.shouldRoutePlayback(synthEngine)) return;
@@ -99,13 +116,19 @@ const scheduleCarlaNote = (synthEngine, channel, pitch, velocity, startWallTime,
// Chn đoán: note MIDI item Carla (bt console.log đ verify route) // Chn đoán: note MIDI item Carla (bt console.log đ verify route)
if (window.__carlaNoteLog === undefined) window.__carlaNoteLog = (window.__carlaNoteLog || 0) + 1; if (window.__carlaNoteLog === undefined) window.__carlaNoteLog = (window.__carlaNoteLog || 0) + 1;
console.log('[Carla] item note ch=' + carlaCh + ' pitch=' + pitch + ' vel=' + carlaVel + ' delay=' + delay.toFixed(0) + 'ms dur=' + (durMs || 300) + 'ms'); console.log('[Carla] item note ch=' + carlaCh + ' pitch=' + pitch + ' vel=' + carlaVel + ' delay=' + delay.toFixed(0) + 'ms dur=' + (durMs || 300) + 'ms');
setTimeout(function () { window.SonicCarlaMidi.noteOn(carlaCh, pitch, carlaVel); }, delay); const dur = durMs || 300;
setTimeout(function () { window.SonicCarlaMidi.noteOff(carlaCh, pitch); }, delay + (durMs || 300) + 30); if (window.__carlaRunning !== true) {
window.__carlaNoteQueue = window.__carlaNoteQueue || [];
window.__carlaNoteQueue.push({ ch: carlaCh, pitch, vel: carlaVel, scheduledAt: Date.now(), delay, durMs: dur });
return;
}
fireCarlaNote(carlaCh, pitch, carlaVel, delay, dur);
} catch (e) { console.warn('[Carla] scheduleCarlaNote error:', e); } } catch (e) { console.warn('[Carla] scheduleCarlaNote error:', e); }
}; };
// Carla bridge alive tracking + auto-open // Carla bridge alive tracking + auto-open
// window.__carlaRunning: undefined = chưa biết | true = đang chy | false = đã chết. // window.__carlaRunning: undefined = chưa biết | true = đang chy | false = đã chết.
// window.__carlaNoteQueue: nt ch flush khi Carla chưa ready (cold start).
// Quyết đnh route MIDI item EXCLUSIVE qua Carla hay fallback FluidSynth // Quyết đnh route MIDI item EXCLUSIVE qua Carla hay fallback FluidSynth
// tránh "câm toàn phn" khi Carla b đóng (route ch-Carla mà Carla chết = im lng). // tránh "câm toàn phn" khi Carla b đóng (route ch-Carla mà Carla chết = im lng).
const refreshCarlaStatus = () => { const refreshCarlaStatus = () => {
@@ -113,26 +136,43 @@ const refreshCarlaStatus = () => {
if (!window.SonicAPI || !window.SonicAPI.carlaStatus) return; if (!window.SonicAPI || !window.SonicAPI.carlaStatus) return;
window.SonicAPI.carlaStatus().then(st => { window.SonicAPI.carlaStatus().then(st => {
window.__carlaRunning = !!(st && st.running); window.__carlaRunning = !!(st && st.running);
if (window.__carlaRunning) flushCarlaNoteQueue();
}).catch(() => { window.__carlaRunning = false; }); }).catch(() => { window.__carlaRunning = false; });
} catch (e) {} } catch (e) {}
}; };
// Carla chưa chy T ĐNG m vi VSTi ca track (fire-and-forget) đ MIDI // Carla chưa chy T ĐNG m vi VSTi ca track ri CH OSC ready (poll
// item play qua Carla bridge đúng yêu cu. // carla-status ti đa 10s) nt ch gi khi bridge thc s nhn đưc.
// Có gate __carlaOpening chng spawn trùng (mi play ch m 1 ln).
const ensureCarlaForPlayback = (synthEngine) => { const ensureCarlaForPlayback = (synthEngine) => {
try { try {
if (!window.SonicCarlaMidi || !window.SonicCarlaMidi.shouldRoutePlayback(synthEngine)) return; if (!window.SonicCarlaMidi || !window.SonicCarlaMidi.shouldRoutePlayback(synthEngine)) return;
if (!window.SonicAPI || !window.SonicAPI.carlaStatus) return; if (!window.SonicAPI || !window.SonicAPI.carlaStatus) return;
if (window.__carlaRunning === true) { flushCarlaNoteQueue(); return; }
if (window.__carlaOpening) return;
window.__carlaOpening = true;
const finish = (ok) => {
window.__carlaRunning = !!ok;
window.__carlaOpening = false;
flushCarlaNoteQueue();
};
window.SonicAPI.carlaStatus().then(st => { window.SonicAPI.carlaStatus().then(st => {
if (st && st.running) { window.__carlaRunning = true; return; } if (st && st.running) { finish(true); return; }
window.__carlaRunning = false;
const pid = synthEngine && synthEngine.plugin_id; const pid = synthEngine && synthEngine.plugin_id;
if (pid) { if (!pid) { finish(false); return; }
window.SonicAPI.openInCarla(pid).then(r => { window.SonicAPI.openInCarla(pid).then(r => {
if (r && r.success) { window.__carlaRunning = true; } if (!r || !r.success) { finish(false); return; }
else { window.__carlaRunning = false; } // Carla spawn (hoc đã chy) poll ti khi OSC ready, mi flush nt
}).catch(() => { window.__carlaRunning = false; }); const deadline = Date.now() + 10000;
} const tick = () => {
}).catch(() => { window.__carlaRunning = false; }); window.SonicAPI.carlaStatus().then(s2 => {
if (s2 && s2.running) { finish(true); return; }
if (Date.now() > deadline) { finish(false); return; }
setTimeout(tick, 400);
}).catch(() => finish(false));
};
tick();
}).catch(() => finish(false));
}).catch(() => finish(false));
} catch (e) {} } catch (e) {}
}; };
@@ -17312,7 +17352,8 @@ const App = () => {
const buildTempPayload = () => { const buildTempPayload = () => {
try { try {
const schemaObj = serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs, masteringSettings); const schemaObj = serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs, masteringSettings);
return JSON.stringify({ data_json: JSON.stringify(schemaObj) }); const cid = (window.SonicStorage && window.SonicStorage.getClientId) ? window.SonicStorage.getClientId() : '';
return JSON.stringify({ data_json: JSON.stringify(schemaObj), client_id: cid });
} catch (e) { return null; } } catch (e) { return null; }
}; };
const saveTempServer = () => { const saveTempServer = () => {
@@ -17348,6 +17389,59 @@ const App = () => {
}; };
}, [currentProjectId, projectName, bpm, tracks, subTabs, sessionTabs, masteringSettings]); }, [currentProjectId, projectName, bpm, tracks, subTabs, sessionTabs, masteringSettings]);
// Realtime sync gia các client (LAN browser Tauri standalone)
// Bug 3: 2 session cùng tài khon không sync. Poll revision nh 3s; khi
// updated_at mi hơn bn đã apply + client_id KHÁC mình ti full temp
// deserialize apply. Skip nếu data_json ging ht bn cui (chng churn
// khi 30s writer bump updated_at vi ni dung không đi). Ping-pong chn
// bi: b qua bn do mình ghi (client_id) + hash-skip khi autosave.
useEffect(() => {
if (!window.SonicAPI || !window.SonicAPI.getTempRevision) return;
const myId = (window.SonicStorage && window.SonicStorage.getClientId) ? window.SonicStorage.getClientId() : '';
let lastAppliedAt = 0;
let lastAppliedJson = '';
let timer = null;
let stopped = false;
const applyRemote = async () => {
try {
const rev = await window.SonicAPI.getTempRevision();
if (!rev || !rev.updated_at) return;
if (rev.client_id === myId) { lastAppliedAt = rev.updated_at; return; } // bn do mình ghi
if (rev.updated_at <= lastAppliedAt) return; // không mi hơn bn đã apply
const tmp = await window.SonicAPI.getTempProject();
if (!tmp || !tmp.has_temp || !tmp.data_json) return;
const projStr = typeof tmp.data_json === 'string' ? tmp.data_json : JSON.stringify(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) return;
if (projStr === lastAppliedJson) { lastAppliedAt = rev.updated_at; return; } // ni dung không đi
const result = deserializeProjectFromSchema(proj);
if (result && result.tracks && result.tracks.length > 0) {
lastAppliedJson = projStr;
lastAppliedAt = rev.updated_at;
setTracks(result.tracks);
loadAudioBuffersForTracks(result.tracks).catch(function (err) { console.warn('loadAudioBuffersForTracks (sync) error:', err); });
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('Đã đồng bộ dự án từ thiết bị khác', 'info');
}
} catch (e) { console.warn('[Sync] error:', e); }
};
const tick = () => {
if (stopped) return;
applyRemote().finally(() => {
if (!stopped) timer = setTimeout(tick, 3000);
});
};
timer = setTimeout(tick, 4000); // sau mount + restore temp
return () => { stopped = true; if (timer) clearTimeout(timer); };
}, []);
// Timer-based auto-save (5 min) + backup (30 min) // Timer-based auto-save (5 min) + backup (30 min)
useEffect(() => { useEffect(() => {
const BACKUP_MAX_KEY = 'sonic_backup_max_count'; const BACKUP_MAX_KEY = 'sonic_backup_max_count';
@@ -27204,7 +27298,7 @@ STRICT CONSTRAINTS:
return { success: true, trackId: rearrangeNewId, totalBars }; return { success: true, trackId: rearrangeNewId, totalBars };
}, },
createMidiItem: (args) => { createMidiItem: (args) => {
const trackId = args.track_id || selectedTrackId; const trackId = String(args.track_id || selectedTrackId);
if (!trackId) return { success: false, error: 'No track_id provided' }; if (!trackId) return { success: false, error: 'No track_id provided' };
const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4;
const startBar = args.start_bar !== undefined ? parseFloat(args.start_bar) : 0; const startBar = args.start_bar !== undefined ? parseFloat(args.start_bar) : 0;
@@ -27215,6 +27309,7 @@ STRICT CONSTRAINTS:
parent_track_id: trackId, parent_track_id: trackId,
startTime: startBar * secondsPerBar, startTime: startBar * secondsPerBar,
duration: lengthBars * secondsPerBar, duration: lengthBars * secondsPerBar,
length_bars: lengthBars,
notes: [], notes: [],
color: '#a78bfa' color: '#a78bfa'
}; };
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -45,8 +45,9 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin;
deleteUser: (userId) => apiRequest(`/api/v1/admin/users/${userId}`, { method: 'DELETE' }), deleteUser: (userId) => apiRequest(`/api/v1/admin/users/${userId}`, { method: 'DELETE' }),
createUser: (username, email, password, role = 'standard') => apiRequest('/api/v1/admin/users', { method: 'POST', body: JSON.stringify({ username, email, password, role }) }), createUser: (username, email, password, role = 'standard') => apiRequest('/api/v1/admin/users', { method: 'POST', body: JSON.stringify({ username, email, password, role }) }),
saveTempProject: (dataJson) => apiRequest('/api/v1/projects/temp', { method: 'POST', body: JSON.stringify({ data_json: dataJson }) }), saveTempProject: (dataJson, clientId) => apiRequest('/api/v1/projects/temp', { method: 'POST', body: JSON.stringify({ data_json: dataJson, client_id: clientId || '' }) }),
getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }), getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }),
getTempRevision: () => apiRequest('/api/v1/projects/temp/revision', { method: 'GET' }),
saveCloudProject: (name, dataJson) => apiRequest('/api/v1/projects/cloud', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }), saveCloudProject: (name, dataJson) => apiRequest('/api/v1/projects/cloud', { method: 'POST', body: JSON.stringify({ name, data_json: dataJson }) }),
listCloudProjects: () => apiRequest('/api/v1/projects/cloud', { method: 'GET' }), listCloudProjects: () => apiRequest('/api/v1/projects/cloud', { method: 'GET' }),
getCloudProject: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}`, { method: 'GET' }), getCloudProject: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}`, { method: 'GET' }),
+23 -3
View File
@@ -61,6 +61,21 @@
let autoSaveTimer = null; let autoSaveTimer = null;
let lastGetProjectStateCallback = null; let lastGetProjectStateCallback = null;
// ⚠️ FIX (Bug 3): hash-skip — nếu state serialize GIỐNG HỆT bản đã lưu
// thì KHÔNG ghi lại (server + localStorage). Chặn ping-pong realtime sync:
// client B apply state của A rồi autosave lại → server bump updated_at →
// A tưởng mới → apply → ... vô hạn. Bản giống hệt → bỏ qua → hội tụ.
let lastSavedJson = '';
function getClientId() {
try {
let c = localStorage.getItem('sonic_client_id');
if (!c) {
c = 'c_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 10);
localStorage.setItem('sonic_client_id', c);
}
return c;
} catch (e) { return ''; }
}
function scheduleTempAutoSave(getProjectStateCallback) { function scheduleTempAutoSave(getProjectStateCallback) {
if (getProjectStateCallback) lastGetProjectStateCallback = getProjectStateCallback; if (getProjectStateCallback) lastGetProjectStateCallback = getProjectStateCallback;
if (autoSaveTimer) clearTimeout(autoSaveTimer); if (autoSaveTimer) clearTimeout(autoSaveTimer);
@@ -69,9 +84,11 @@
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null; const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
if (!state || (!state.tracks && !state.main_session)) return; if (!state || (!state.tracks && !state.main_session)) return;
const dataJson = JSON.stringify(state); const dataJson = JSON.stringify(state);
if (dataJson === lastSavedJson) return; // không đổi → không ghi
lastSavedJson = dataJson;
localStorage.setItem('sonic_temp_project', dataJson); localStorage.setItem('sonic_temp_project', dataJson);
if (window.SonicAPI && localStorage.getItem('sonic_token')) { if (window.SonicAPI && localStorage.getItem('sonic_token')) {
await window.SonicAPI.saveTempProject(dataJson).catch(() => {}); await window.SonicAPI.saveTempProject(dataJson, getClientId()).catch(() => {});
} }
} catch (e) { } catch (e) {
console.warn("Auto-save temp project warning:", e); console.warn("Auto-save temp project warning:", e);
@@ -85,9 +102,11 @@
const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null; const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null;
if (!state || (!state.tracks && !state.main_session)) return; if (!state || (!state.tracks && !state.main_session)) return;
const dataJson = JSON.stringify(state); const dataJson = JSON.stringify(state);
if (dataJson === lastSavedJson) return; // không đổi → không ghi
lastSavedJson = dataJson;
localStorage.setItem('sonic_temp_project', dataJson); localStorage.setItem('sonic_temp_project', dataJson);
if (window.SonicAPI && localStorage.getItem('sonic_token')) { if (window.SonicAPI && localStorage.getItem('sonic_token')) {
await window.SonicAPI.saveTempProject(dataJson).catch(() => {}); await window.SonicAPI.saveTempProject(dataJson, getClientId()).catch(() => {});
} }
} catch (e) { } catch (e) {
console.warn("Flush temp project warning:", e); console.warn("Flush temp project warning:", e);
@@ -98,6 +117,7 @@
exportProjectToSFS, exportProjectToSFS,
importProjectFromSFSFile, importProjectFromSFSFile,
scheduleTempAutoSave, scheduleTempAutoSave,
flushTempAutoSave flushTempAutoSave,
getClientId
}; };
})(); })();
+1 -1
View File
@@ -46,7 +46,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=202608102044" defer></script> <script src="/static/js/app.precompiled.js?v=202608102202" 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 {