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:
+42
-4
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 và 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 NÓ 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
@@ -89,6 +89,23 @@ const ensureSonicInstrument = (ctx) => {
|
|||||||
// track dùng VSTi + có Carla local. Dùng chung cho mọi đường playback
|
// 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
|
// (main timeline, local loop, piano roll, ghost notes) để MIDI item PHẢI 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) — nốt gửi
|
||||||
|
// trước khi OSC engine bind cổng bị mất → item câm dù keybed kêu. Nốt 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,
|
|||||||
// Chẩn đoán: note MIDI item → Carla (bật console.log để verify route)
|
// Chẩn đoán: note MIDI item → Carla (bật 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 chạy | false = đã chết.
|
// window.__carlaRunning: undefined = chưa biết | true = đang chạy | false = đã chết.
|
||||||
|
// window.__carlaNoteQueue: nốt 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 phần" khi Carla bị đóng (route chỉ-Carla mà Carla chết = im lặng).
|
// tránh "câm toàn phần" khi Carla bị đóng (route chỉ-Carla mà Carla chết = im lặng).
|
||||||
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 chạy → TỰ ĐỘNG mở với VSTi của track (fire-and-forget) để MIDI
|
// Carla chưa chạy → TỰ ĐỘNG mở với VSTi của track rồi CHỜ OSC ready (poll
|
||||||
// item play qua Carla bridge đúng yêu cầu.
|
// carla-status tối đa 10s) — nốt chỉ gửi khi bridge thực sự nhận được.
|
||||||
|
// Có gate __carlaOpening chống spawn trùng (mỗi play chỉ mở 1 lần).
|
||||||
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 (hoặc đã chạy) — poll tới khi OSC ready, mới flush nốt
|
||||||
}).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 giữa các client (LAN browser ↔ Tauri standalone) ──
|
||||||
|
// Bug 3: 2 session cùng tài khoản không sync. Poll revision nhẹ 3s; khi
|
||||||
|
// updated_at mới hơn bản đã apply + client_id KHÁC mình → tải full temp →
|
||||||
|
// deserialize → apply. Skip nếu data_json giống hệt bản cuối (chống churn
|
||||||
|
// khi 30s writer bump updated_at với nội dung không đổi). Ping-pong chặn
|
||||||
|
// bởi: bỏ qua bản 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; } // bản do mình ghi
|
||||||
|
if (rev.updated_at <= lastAppliedAt) return; // không mới hơn bản đã 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; } // nội 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
@@ -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' }),
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user