From aa1cd8d5de36316dd7fd8b5bda8ace8b82ff90b9 Mon Sep 17 00:00:00 2001 From: locpham Date: Mon, 10 Aug 2026 22:10:12 +0700 Subject: [PATCH] =?UTF-8?q?fix:=203=20bug=20standalone=20=E2=80=94=20sound?= =?UTF-8?q?font=20preview/play,=20Carla=20bridge=20play=20item,=20realtime?= =?UTF-8?q?=20sync=20LAN/Tauri?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/api/v1/plugins.py | 46 ++++++++++- app/api/v1/projects.py | 36 +++++++++ app/static/js/app.jsx | 125 ++++++++++++++++++++++++++---- app/static/js/app.precompiled.js | 32 ++++++-- app/static/js/services/api.js | 3 +- app/static/js/services/storage.js | 26 ++++++- app/templates/index.html | 2 +- 7 files changed, 238 insertions(+), 32 deletions(-) diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index dfc1d15..13f5bfe 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -563,6 +563,25 @@ def _carla_osc_port() -> int: 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: """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 = "" if 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] if carxs_path: cmd.append(carxs_path) @@ -683,15 +716,18 @@ def _send_carla_all_notes_off() -> bool: @router.get("/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 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() return { "success": True, - "running": len(_CARLA_PROCESSES) > 0, + "running": _carla_osc_ready(), + "app_spawned": len(_CARLA_PROCESSES) > 0, "osc_port": _carla_osc_port(), } @@ -1118,7 +1154,9 @@ def find_carla_local() -> str: def _carla_bridge_running() -> bool: try: _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: return False diff --git a/app/api/v1/projects.py b/app/api/v1/projects.py index 3dd21c4..c5c8290 100644 --- a/app/api/v1/projects.py +++ b/app/api/v1/projects.py @@ -136,6 +136,7 @@ class SaveProjectRequest(BaseModel): class SaveTempProjectRequest(BaseModel): 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]: 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) 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) + # 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: pass 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 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: """Thư mục dự án trên hệ điều hành (Ctrl-S desktop): Windows → Documents/SonicForgeDAW/Projects (fallback USERPROFILE); diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 9a2882c..1d54119 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -89,6 +89,23 @@ const ensureSonicInstrument = (ctx) => { // track dùng VSTi + có Carla local. Dùng chung cho mọi đường playback // (main timeline, local loop, piano roll, ghost notes) để MIDI item PHẢI play // qua Carla bridge khi VSTi được loaded. +const 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) => { try { 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) 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'); - setTimeout(function () { window.SonicCarlaMidi.noteOn(carlaCh, pitch, carlaVel); }, delay); - setTimeout(function () { window.SonicCarlaMidi.noteOff(carlaCh, pitch); }, delay + (durMs || 300) + 30); + const dur = durMs || 300; + 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); } }; // ── Carla bridge alive tracking + auto-open ──────────────────────────────── // 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 — // tránh "câm toàn phần" khi Carla bị đóng (route chỉ-Carla mà Carla chết = im lặng). const refreshCarlaStatus = () => { @@ -113,26 +136,43 @@ const refreshCarlaStatus = () => { if (!window.SonicAPI || !window.SonicAPI.carlaStatus) return; window.SonicAPI.carlaStatus().then(st => { window.__carlaRunning = !!(st && st.running); + if (window.__carlaRunning) flushCarlaNoteQueue(); }).catch(() => { window.__carlaRunning = false; }); } catch (e) {} }; -// Carla chưa chạy → TỰ ĐỘNG mở với VSTi của track (fire-and-forget) để MIDI -// item play qua Carla bridge đúng yêu cầu. +// Carla chưa chạy → TỰ ĐỘNG mở với VSTi của track rồi CHỜ OSC ready (poll +// 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) => { try { if (!window.SonicCarlaMidi || !window.SonicCarlaMidi.shouldRoutePlayback(synthEngine)) 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 => { - if (st && st.running) { window.__carlaRunning = true; return; } - window.__carlaRunning = false; + if (st && st.running) { finish(true); return; } const pid = synthEngine && synthEngine.plugin_id; - if (pid) { - window.SonicAPI.openInCarla(pid).then(r => { - if (r && r.success) { window.__carlaRunning = true; } - else { window.__carlaRunning = false; } - }).catch(() => { window.__carlaRunning = false; }); - } - }).catch(() => { window.__carlaRunning = false; }); + if (!pid) { finish(false); return; } + window.SonicAPI.openInCarla(pid).then(r => { + if (!r || !r.success) { finish(false); return; } + // Carla spawn (hoặc đã chạy) — poll tới khi OSC ready, mới flush nốt + const deadline = Date.now() + 10000; + const tick = () => { + 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) {} }; @@ -17312,7 +17352,8 @@ const App = () => { const buildTempPayload = () => { try { const schemaObj = serializeProjectToSchema(currentProjectId || 'temp_project', projectName || 'Dự án tạm chưa lưu', bpm, tracks, subTabs, sessionTabs, masteringSettings); - return JSON.stringify({ data_json: JSON.stringify(schemaObj) }); + 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; } }; const saveTempServer = () => { @@ -17348,6 +17389,59 @@ const App = () => { }; }, [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) ── useEffect(() => { const BACKUP_MAX_KEY = 'sonic_backup_max_count'; @@ -27204,7 +27298,7 @@ STRICT CONSTRAINTS: return { success: true, trackId: rearrangeNewId, totalBars }; }, 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' }; const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4; const startBar = args.start_bar !== undefined ? parseFloat(args.start_bar) : 0; @@ -27215,6 +27309,7 @@ STRICT CONSTRAINTS: parent_track_id: trackId, startTime: startBar * secondsPerBar, duration: lengthBars * secondsPerBar, + length_bars: lengthBars, notes: [], color: '#a78bfa' }; diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index dd2204a..12540bf 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -20,14 +20,20 @@ const ensureSonicInstrument=ctx=>{try{if(!window.SonicSF||!window.SonicSF.select // track dùng VSTi + có Carla local. Dùng chung cho mọi đường playback // (main timeline, local loop, piano roll, ghost notes) để MIDI item PHẢI play // qua Carla bridge khi VSTi được loaded. -const scheduleCarlaNote=(synthEngine,channel,pitch,velocity,startWallTime,durMs)=>{try{if(!window.SonicCarlaMidi||!window.SonicCarlaMidi.shouldRoutePlayback(synthEngine))return;const ctx=getAudioContext();const delay=Math.max(0,(startWallTime-ctx.currentTime)*1000);const carlaVel=Math.round((velocity||0.8)*127);const carlaCh=synthEngine&&synthEngine.midi_channel!==undefined?synthEngine.midi_channel:channel||0;// Chẩn đoán: note MIDI item → Carla (bật console.log để verify route) -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');setTimeout(function(){window.SonicCarlaMidi.noteOn(carlaCh,pitch,carlaVel);},delay);setTimeout(function(){window.SonicCarlaMidi.noteOff(carlaCh,pitch);},delay+(durMs||300)+30);}catch(e){console.warn('[Carla] scheduleCarlaNote error:',e);}};// ── Carla bridge alive tracking + auto-open ──────────────────────────────── +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)=>{try{if(!window.SonicCarlaMidi||!window.SonicCarlaMidi.shouldRoutePlayback(synthEngine))return;const ctx=getAudioContext();const delay=Math.max(0,(startWallTime-ctx.currentTime)*1000);const carlaVel=Math.round((velocity||0.8)*127);const carlaCh=synthEngine&&synthEngine.midi_channel!==undefined?synthEngine.midi_channel:channel||0;// Chẩn đoán: note MIDI item → Carla (bật console.log để verify route) +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');const dur=durMs||300;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);}};// ── Carla bridge alive tracking + auto-open ──────────────────────────────── // 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 — // tránh "câm toàn phần" khi Carla bị đóng (route chỉ-Carla mà Carla chết = im lặng). -const refreshCarlaStatus=()=>{try{if(!window.SonicAPI||!window.SonicAPI.carlaStatus)return;window.SonicAPI.carlaStatus().then(st=>{window.__carlaRunning=!!(st&&st.running);}).catch(()=>{window.__carlaRunning=false;});}catch(e){}};// Carla chưa chạy → TỰ ĐỘNG mở với VSTi của track (fire-and-forget) để MIDI -// item play qua Carla bridge đúng yêu cầu. -const ensureCarlaForPlayback=synthEngine=>{try{if(!window.SonicCarlaMidi||!window.SonicCarlaMidi.shouldRoutePlayback(synthEngine))return;if(!window.SonicAPI||!window.SonicAPI.carlaStatus)return;window.SonicAPI.carlaStatus().then(st=>{if(st&&st.running){window.__carlaRunning=true;return;}window.__carlaRunning=false;const pid=synthEngine&&synthEngine.plugin_id;if(pid){window.SonicAPI.openInCarla(pid).then(r=>{if(r&&r.success){window.__carlaRunning=true;}else{window.__carlaRunning=false;}}).catch(()=>{window.__carlaRunning=false;});}}).catch(()=>{window.__carlaRunning=false;});}catch(e){}};// ── Môi trường chạy: docker vs standalone ───────────────────────────────── +const refreshCarlaStatus=()=>{try{if(!window.SonicAPI||!window.SonicAPI.carlaStatus)return;window.SonicAPI.carlaStatus().then(st=>{window.__carlaRunning=!!(st&&st.running);if(window.__carlaRunning)flushCarlaNoteQueue();}).catch(()=>{window.__carlaRunning=false;});}catch(e){}};// Carla chưa chạy → TỰ ĐỘNG mở với VSTi của track rồi CHỜ OSC ready (poll +// 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=>{try{if(!window.SonicCarlaMidi||!window.SonicCarlaMidi.shouldRoutePlayback(synthEngine))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=>{if(st&&st.running){finish(true);return;}const pid=synthEngine&&synthEngine.plugin_id;if(!pid){finish(false);return;}window.SonicAPI.openInCarla(pid).then(r=>{if(!r||!r.success){finish(false);return;}// Carla spawn (hoặc đã chạy) — poll tới khi OSC ready, mới flush nốt +const deadline=Date.now()+10000;const tick=()=>{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){}};// ── Môi trường chạy: docker vs standalone ───────────────────────────────── // environment: "docker" → âm instrument qua FluidSynthWASM (client); "standalone" // → xử lí trực tiếp trên OS (backend native pyfluidsynth / Carla). Quy tắc: // KHÔNG phải docker = standalone (Windows/Linux/macOS chạy trực tiếp). @@ -980,9 +986,19 @@ useEffect(()=>{const serializeSafe=arr=>(arr||[]).map(t=>({id:t.id,name:t.name,s // 1) đóng tab/window (beforeunload/pagehide — sendBeacon keepalive) // 2) định kỳ 30s khi có thay đổi (phòng Tauri kill engine ngay khi close) // Load lại app → khôi phục dự án đang làm dở (getTempProject ở mount). -useEffect(()=>{const buildTempPayload=()=>{try{const schemaObj=serializeProjectToSchema(currentProjectId||'temp_project',projectName||'Dự án tạm chưa lưu',bpm,tracks,subTabs,sessionTabs,masteringSettings);return JSON.stringify({data_json:JSON.stringify(schemaObj)});}catch(e){return null;}};const saveTempServer=()=>{const payload=buildTempPayload();if(!payload)return;try{if(navigator.sendBeacon){// sendBeacon không đặt header JSON được — backend đọc body JSON thuần +useEffect(()=>{const buildTempPayload=()=>{try{const schemaObj=serializeProjectToSchema(currentProjectId||'temp_project',projectName||'Dự án tạm chưa lưu',bpm,tracks,subTabs,sessionTabs,masteringSettings);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;}};const saveTempServer=()=>{const payload=buildTempPayload();if(!payload)return;try{if(navigator.sendBeacon){// sendBeacon không đặt header JSON được — backend đọc body JSON thuần const blob=new Blob([payload],{type:'application/json'});navigator.sendBeacon(window.API_BASE_URL+'/api/v1/projects/temp',blob);}else{fetch(window.API_BASE_URL+'/api/v1/projects/temp',{method:'POST',headers:{'Content-Type':'application/json'},body:payload,keepalive:true}).catch(()=>{});}}catch(e){/* fire-and-forget */}};const onUnload=e=>{saveTempServer();// Để sendBeacon có cơ hội gửi trước khi WebView bị destroy -try{navigator.sendBeacon&&navigator.sendBeacon(window.API_BASE_URL+'/health',new Blob(['ping'],{type:'text/plain'}));}catch(e2){}};window.addEventListener('beforeunload',onUnload);window.addEventListener('pagehide',onUnload);const interval=setInterval(saveTempServer,30*1000);return()=>{window.removeEventListener('beforeunload',onUnload);window.removeEventListener('pagehide',onUnload);clearInterval(interval);};},[currentProjectId,projectName,bpm,tracks,subTabs,sessionTabs,masteringSettings]);// ── Timer-based auto-save (5 min) + backup (30 min) ── +try{navigator.sendBeacon&&navigator.sendBeacon(window.API_BASE_URL+'/health',new Blob(['ping'],{type:'text/plain'}));}catch(e2){}};window.addEventListener('beforeunload',onUnload);window.addEventListener('pagehide',onUnload);const interval=setInterval(saveTempServer,30*1000);return()=>{window.removeEventListener('beforeunload',onUnload);window.removeEventListener('pagehide',onUnload);clearInterval(interval);};},[currentProjectId,projectName,bpm,tracks,subTabs,sessionTabs,masteringSettings]);// ── 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) ── useEffect(()=>{const BACKUP_MAX_KEY='sonic_backup_max_count';if(!localStorage.getItem(BACKUP_MAX_KEY)){localStorage.setItem(BACKUP_MAX_KEY,'10');}const interval5=setInterval(()=>{if(!currentProjectId)return;// Auto-save: giống handleSaveProject if(currentProjectId.startsWith('local_')){const finalName=projectName||'Dự án mới';const localId=currentProjectId;const schemaObj=serializeProjectToSchema(localId,finalName,bpm,tracks,subTabs,sessionTabs,masteringSettings);const dataStr=JSON.stringify(schemaObj);localStorage.setItem('sonic_local_project_data',dataStr);localStorage.setItem('sonic_project_id',localId);localStorage.setItem('sonic_project_name',finalName);}else if(currentUser&&window.SonicAPI){try{const schemaObj=serializeProjectToSchema(currentProjectId,projectName,bpm,tracks,subTabs,sessionTabs,masteringSettings);const dataJson=JSON.stringify(schemaObj);window.SonicAPI.updateCloudProject(currentProjectId,projectName||'Dự án mới',dataJson).catch(()=>{});}catch(e){console.warn('Auto-save error:',e);}}},5*60*1000);const interval30=setInterval(()=>{if(!currentProjectId||currentProjectId.startsWith('local_')||!currentUser||!window.SonicAPI)return;try{window.SonicAPI.createBackup(currentProjectId).catch(()=>{});}catch(e){console.warn('Backup error:',e);}},30*60*1000);return()=>{clearInterval(interval5);clearInterval(interval30);};},[currentProjectId,projectName,currentUser,tracks,subTabs,sessionTabs,masteringSettings,bpm]);// Lucide icons initialization useEffect(()=>{setTimeout(()=>{if(window.lucide){window.lucide.createIcons();}},50);},[activeTool,activeTab,contextMenu,activeTracks]);const timelineWrapperRef=useRef(null);const[timelineWrapperNode,setTimelineWrapperNode]=useState(null);const handleTimelineWrapperRef=useCallback(node=>{timelineWrapperRef.current=node;setTimelineWrapperNode(node);},[]);const tcpContainerRef=useRef(null);const[scrollLeft,setScrollLeft]=useState(0);const handleTimelineScroll=e=>{if(tcpContainerRef.current){tcpContainerRef.current.scrollTop=e.currentTarget.scrollTop;}setScrollLeft(e.currentTarget.scrollLeft);};const handleTCPScroll=e=>{if(timelineWrapperRef.current){timelineWrapperRef.current.scrollTop=e.currentTarget.scrollTop;}};const panelDropZoneRef=useRef(null);const startPanelDrag=(panelId,e)=>{panelDragRef.current={panelId,startX:e.clientX,startY:e.clientY};setPanelDropZone(null);setDragGhostPanel(panelId);setDragGhostPos({x:e.clientX-120,y:e.clientY-20});const onMove=ev=>{if(!panelDragRef.current||!workspaceRef.current)return;const rect=workspaceRef.current.getBoundingClientRect();const x=ev.clientX-rect.left;const y=ev.clientY-rect.top;const w=rect.width;const h=rect.height;const margin=60;let zone=null;if(x10)zone='left';else if(x>w-margin&&x10)zone='top';else if(y>h-margin&&y{if(panelDragRef.current){const pid=panelDragRef.current.panelId;const targetZone=panelDropZoneRef.current;if(targetZone&&targetZone!==panelPositions[pid]){setPanelPositions(prev=>({...prev,[pid]:targetZone}));}panelDragRef.current=null;panelDropZoneRef.current=null;setPanelDropZone(null);setDragGhostPos(null);setDragGhostPanel(null);}document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);};// ── Right Sidebar Column Resizer ── @@ -1700,7 +1716,7 @@ const handleSplitTrackAtTime=(trackId,clipId,time)=>{const track=tracks.find(t=> const handleGlueTracks=()=>{const track=tracks.find(t=>t.id===selectedTrackId);if(!track){showToast('Vui lòng chọn một track để thực hiện gộp (glue).','warning');return;}const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];if(clips.length<2){showToast('Cần ít nhất 2 clip trên track này để gộp (glue).','warning');return;}const beforeSnap=captureTrackSnapshot(track.id);const ctx=getAudioContext();const sr=clips[0].buffer.sampleRate;let minStart=Infinity;let maxEnd=-Infinity;clips.forEach(c=>{const start=c.startTime||0;const end=start+c.buffer.duration;minStart=Math.min(minStart,start);maxEnd=Math.max(maxEnd,end);});const newDur=maxEnd-minStart;const newBuffer=ctx.createBuffer(1,Math.ceil(newDur*sr),sr);const newData=newBuffer.getChannelData(0);clips.forEach(c=>{const data=c.buffer.getChannelData(0);const offset=Math.floor(((c.startTime||0)-minStart)*sr);for(let i=0;imaxPeak)maxPeak=abs;}if(maxPeak>1.0){for(let i=0;iprev.map(t=>{if(t.id===track.id){return{...t,clips:[mergedClip],buffer:newBuffer,startTime:minStart,name:mergedClip.name};}return t;}));setTimeout(()=>{const afterSnap=captureTrackSnapshot(track.id);pushAction('GLUE',track.id,beforeSnap,afterSnap);},50);showToast(`Đã gộp ${clips.length} clips thành công.`,'success');};// ── DAW Command Registration for AI (28_AI_PANEL.md §1 & §2) ── useEffect(()=>{if(typeof window.DAWCommandDispatcher==='undefined')return;const api={createTrack:args=>{const name=args.name||`AI_Track_${Date.now()}`;const type=args.type||'audio';const rearrangeNewId=addNewTrack();if(name&&name!==`AI_Track_${Date.now()}`){updateTrackName(rearrangeNewId,name);}return{success:true,trackId:rearrangeNewId,name};},deleteTrack:args=>{const tid=args.track_id||selectedTrackId;if(!tid)return{success:false,error:'No track_id provided'};deleteTrack(tid);return{success:true,trackId:tid};},addClip:args=>{const trackId=args.track_id||selectedTrackId;const barDur=60/parseInt(bpm||120)*4;let startTime;if(args.start_time!==undefined&&args.start_time!==null)startTime=args.start_time;else if(args.start_bar!==undefined&&args.start_bar!==null)startTime=args.start_bar*barDur;else startTime=currentTime;const track=tracks.find(t=>t.id===trackId);if(!track)return{success:false,error:'Track not found'};const ctx=getAudioContext();const sr=44100;let duration;if(args.duration_seconds!==undefined&&args.duration_seconds!==null)duration=args.duration_seconds;else if(args.length_bars!==undefined&&args.length_bars!==null)duration=args.length_bars*barDur;else duration=2;const buffer=ctx.createBuffer(1,Math.floor(sr*duration),sr);const data=buffer.getChannelData(0);for(let i=0;iprev.map(t=>{if(t.id!==trackId)return t;const clips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default_'+t.id,buffer:t.buffer,startTime:t.startTime||0,name:t.name}]:[];return{...t,clips:[...clips,{id:clipId,buffer,startTime,name:args.name||'AI Clip'}],buffer:clips.length>0?clips[0].buffer:buffer,startTime:clips.length>0?clips[0].startTime:startTime,name:clips.length>0?clips[0].name:args.name||t.name};}));return{success:true,clipId,trackId};},removeClip:args=>{const trackId=args.track_id||selectedTrackId;const clipId=args.clip_id;setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;const updatedClips=(t.clips||[]).filter(c=>c.id!==clipId);return{...t,clips:updatedClips,buffer:updatedClips[0]?.buffer||null,startTime:updatedClips[0]?.startTime||0,name:updatedClips[0]?.name||t.name};}));return{success:true};},setTrackVolume:args=>{const trackId=args.track_id||selectedTrackId;const vol=args.volume_db??args.volume??0;updateTrackVolumeDb(trackId,parseFloat(vol));return{success:true,trackId,volumeDb:vol};},setTrackPan:args=>{const trackId=args.track_id||selectedTrackId;const pan=args.pan??0;updateTrackPan(trackId,parseInt(pan));return{success:true,trackId,pan};},toggleMute:args=>{const trackId=args.track_id||selectedTrackId;toggleTrackMute(trackId);const track=tracks.find(t=>t.id===trackId);return{success:true,trackId,muted:track?track.muted:null};},toggleSolo:args=>{const trackId=args.track_id||selectedTrackId;toggleTrackSoloEvaluate(trackId);const track=tracks.find(t=>t.id===trackId);return{success:true,trackId,solo:track?track.solo:null};},processAudioDsp:args=>{const trackId=args.track_id||selectedTrackId;const action=args.action;const params=args.params||{};const track=tracks.find(t=>t.id===trackId);if(!track||!track.buffer)return{success:false,error:'Track has no audio buffer'};if(action==='normalize'){const channelData=track.buffer.getChannelData(0);let maxVal=0;for(let i=0;i0){const gain=1.0/maxVal;for(let i=0;i{const newLen=Math.round(data.length*r);const out=new Float32Array(newLen);for(let i=0;iprev.map(t=>t.id===trackId?{...t,buffer:newBuffer}:t));return{success:true,action:'pitch_shift',semitones};}return{success:false,error:`Unknown action: ${action}`};},renameTrack:args=>{const tid=args.track_id||selectedTrackId;const name=args.name;if(!tid)return{success:false,error:'No track_id provided'};if(!name)return{success:false,error:'No name provided'};updateTrackName(tid,name);return{success:true,trackId:tid,name};},setSelection:args=>{const barDur=60/parseInt(bpm||120)*4;let start,end;if(args.start_time!==undefined&&args.start_time!==null)start=args.start_time;else if(args.start_bar!==undefined&&args.start_bar!==null)start=args.start_bar*barDur;else start=currentTime;if(args.end_time!==undefined&&args.end_time!==null)end=args.end_time;else if(args.length_bars!==undefined&&args.length_bars!==null)end=start+args.length_bars*barDur;else if(args.end_bar!==undefined&&args.end_bar!==null)end=args.end_bar*barDur;else end=start+barDur;clearLocalSelection();setSelectionMode('global');setSelectionStart(start);setSelectionEnd(end);selectionRef.current={start,end};return{success:true,start:parseFloat(start.toFixed(3)),end:parseFloat(end.toFixed(3)),length:parseFloat((end-start).toFixed(3))};},cutAudio:args=>{const currentTracks=window.DAWCommandDispatcher?.currentTracks||tracks;const currentSelTrackId=window.DAWCommandDispatcher?.currentSelectedTrackId||selectedTrackId;const tid=args.track_id||currentSelTrackId;const track=currentTracks.find(t=>t.id===String(tid));if(!track)return{success:false,error:'Track not found'};if(!track.buffer)return{success:false,error:'Track has no audio buffer'};const barDur=60/parseInt(bpm||120)*4;const sel=selectionRef.current;let rawStart,rawEnd;if(args.start_time!==undefined&&args.start_time!==null)rawStart=args.start_time;else if(args.start_bar!==undefined&&args.start_bar!==null)rawStart=args.start_bar*barDur;else if(sel.start!==null)rawStart=sel.start;else rawStart=currentTime;if(args.end_time!==undefined&&args.end_time!==null)rawEnd=args.end_time;else if(args.end_bar!==undefined&&args.end_bar!==null)rawEnd=args.end_bar*barDur;else if(args.length_bars!==undefined&&args.length_bars!==null)rawEnd=rawStart+args.length_bars*barDur;else if(sel.end!==null&&sel.end>rawStart)rawEnd=sel.end;else return{success:false,error:'No end position provided. Provide end_time, end_bar, or length_bars.'};if(rawEnd<=rawStart)return{success:false,error:'End position must be after start position.'};const buffer=track.buffer;const ctx=getAudioContext();const snap=args.snap_silence!==false;const loopStart=snap?findZeroCrossing(buffer,rawStart):rawStart;const loopEnd=snap?findZeroCrossing(buffer,rawEnd):rawEnd;const sampleRate=buffer.sampleRate;const startSample=Math.max(0,Math.min(buffer.length-1,Math.floor(loopStart*sampleRate)));const endSample=Math.max(0,Math.min(buffer.length,Math.floor(loopEnd*sampleRate)));const sliceLength=endSample-startSample;if(sliceLength<=100)return{success:false,error:'Selection too short or invalid'};const numChannels=buffer.numberOfChannels||1;const slicedBuffer=ctx.createBuffer(numChannels,sliceLength,sampleRate);for(let c=0;ct.id===tid);const nextTracks=[...currentTracks];if(idx!==-1){nextTracks.splice(idx+1,0,newTrack);}else{nextTracks.push(newTrack);}if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.currentTracks=nextTracks;window.DAWCommandDispatcher.currentSelectedTrackId=rearrangeNewId;window.DAWCommandDispatcher.lastCutSourceTrackId=tid;window.DAWCommandDispatcher.lastCutNewTrackId=rearrangeNewId;}setTracks(nextTracks);setSelectedTrackId(rearrangeNewId);selectionRef.current={start:0,end:slicedBuffer.duration};clearLocalSelection();setSelectionMode('global');setSelectionStart(0);setSelectionEnd(slicedBuffer.duration);setTimeout(()=>lucide.createIcons(),200);return{success:true,trackId:rearrangeNewId,trackName:cutName,cutStart:parseFloat(loopStart.toFixed(3)),cutEnd:parseFloat(loopEnd.toFixed(3)),duration:parseFloat(slicedBuffer.duration.toFixed(3))};},scanTrack:args=>{const tid=args.track_id||selectedTrackId;const track=tracks.find(t=>t.id===tid);if(!track)return{success:false,error:'Track not found'};if(!track.buffer)return{success:false,error:'Track has no audio buffer. Load audio first.'};const buffer=track.buffer;const data=buffer.getChannelData(0);const sr=buffer.sampleRate;const channels=buffer.numberOfChannels;const duration=buffer.duration;const totalSamples=buffer.length;const windowSize=Math.min(sr*3,data.length);let detectedBPM=0;if(windowSize>sr){let maxCorr=0;for(let lag=Math.floor(sr*0.3);lag<=Math.floor(sr*2.0);lag++){let corr=0;const step=4;for(let i=0;imaxCorr){maxCorr=corr;detectedBPM=60/(lag/sr);}}}detectedBPM=Math.round(Math.min(300,Math.max(30,detectedBPM)));if(args.set_tempo!==false&&detectedBPM>0){setBpm(String(detectedBPM));}const bitDepth=16;const bitrate=Math.round(sr*channels*bitDepth/1000);return{success:true,trackId:tid,trackName:track.name,bpm:detectedBPM,sampleRate:sr,channels,duration:parseFloat(duration.toFixed(3)),totalSamples,bitDepth,bitrateKbps:bitrate,hasAudio:true};},setBpm:args=>{const bpmVal=args.bpm||args.tempo||120;setBpm(String(bpmVal));return{success:true,bpm:bpmVal};},setPlayhead:args=>{const barDur=60/parseInt(bpm||120)*4;let time;if(args.time!==undefined&&args.time!==null)time=args.time;else if(args.bar!==undefined&&args.bar!==null)time=args.bar*barDur;else time=0;handlePlayheadSet(time);return{success:true,time:parseFloat(time.toFixed(3))};},exportAudio:async args=>{const currentTracks=window.DAWCommandDispatcher?.currentTracks||tracks;const currentSelTrackId=window.DAWCommandDispatcher?.currentSelectedTrackId||selectedTrackId;let tid=args.track_id;if(tid&&window.DAWCommandDispatcher?.lastCutSourceTrackId&&(String(tid)===String(window.DAWCommandDispatcher.lastCutSourceTrackId)||'track_'+tid===String(window.DAWCommandDispatcher.lastCutSourceTrackId))){tid=window.DAWCommandDispatcher.lastCutNewTrackId;}if(!tid)tid=currentSelTrackId;const track=tid&¤tTracks.find(t=>t.id===String(tid)||t.id==='track_'+tid);if(!track)return{success:false,error:'No track found'};const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];if(clips.length===0&&(!track.midiItems||track.midiItems.length===0))return{success:false,error:'Track has no audio clips'};const beatsPerSec=parseFloat(bpm||120)/60;const totalDuration=Math.max(clips.length>0?Math.max(...clips.map(c=>(c.startTime||0)+(c.buffer?c.buffer.duration/(c.speed||1.0):0))):0,...(track.midiItems||[]).map(m=>(m.startTime||0)+(m.duration||4)),...(track.sections||[]).map(s=>(s.start||0)+(s.duration||4)));const barDur=60/parseInt(bpm||120)*4;const sel=selectionRef.current;let rawStart,rawEnd;if(args.start_time!==undefined)rawStart=args.start_time;else if(args.start_bar!==undefined)rawStart=args.start_bar*barDur;else if(sel.start!==null)rawStart=sel.start;else rawStart=0;if(args.end_time!==undefined)rawEnd=args.end_time;else if(args.length_bars!==undefined)rawEnd=(rawStart||0)+args.length_bars*barDur;else if(args.end_bar!==undefined)rawEnd=args.end_bar*barDur;else if(sel.end!==null&&sel.end>rawStart)rawEnd=sel.end;else rawEnd=totalDuration;if(rawEnd<=rawStart)return{success:false,error:'Export range is empty or invalid.'};const ctx=getAudioContext();const sr=parseInt(args.sample_rate||'44100');const firstBuffer=clips.find(c=>c.buffer)?.buffer;const numCh=args.channels==='mono'?1:firstBuffer?firstBuffer.numberOfChannels:2;const bd=parseInt(args.bit_depth||'16');const fmt=args.format||'wav';const renderLength=rawEnd-rawStart;const offlineCtx=new OfflineAudioContext(numCh,Math.ceil(sr*renderLength),sr);clips.forEach(clip=>{if(!clip.buffer)return;const clipStart=clip.startTime||0;const clipDuration=clip.buffer.duration/(clip.speed||1.0);const clipEnd=clipStart+clipDuration;if(clipEnd<=rawStart||clipStart>=rawEnd)return;const source=offlineCtx.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(offlineCtx.destination);if(rawStart{for(let i=0;i>8&0xFF);vw.setUint8(ofs+2,v24>>16&0xFF);}ofs+=bps;}}const blob=new Blob([fileBuf],{type:'audio/wav'});const localUrl=URL.createObjectURL(blob);const targetFilename=`export_${Date.now()}.${fmt}`;const triggerDownload=(downloadUrl,finalFilename)=>{if(window.DAWCommandDispatcher?.isExecutingAI){showToast(`Xuất nhạc thành công (${fmt.toUpperCase()})!`,"success","Tải về",()=>{const a=document.createElement('a');a.href=downloadUrl;a.download=finalFilename;a.click();if(downloadUrl.startsWith('blob:')){URL.revokeObjectURL(downloadUrl);}});}else{const a=document.createElement('a');a.href=downloadUrl;a.download=finalFilename;a.click();showToast("Xuất bản âm thanh hoàn tất!","success");if(downloadUrl.startsWith('blob:')){URL.revokeObjectURL(downloadUrl);}}};if((fmt==='mp3'||fmt==='ogg')&&serverStatus==='connected'){try{const file=new File([blob],`export_ai.wav`,{type:'audio/wav'});const formData=new FormData();formData.append('file',file);const uploadResp=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:formData});if(!uploadResp.ok)throw new Error("Upload failed");const uploadData=await uploadResp.json();const uploadId=uploadData.file_id;const exportResp=await fetch(`${API_AUDIO}/export`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:uploadId,format:fmt,sample_rate:sr,bit_depth:bd})});if(!exportResp.ok)throw new Error("Export failed");const exportData=await exportResp.json();const result=await pollTaskResult(exportData.task_id,20);if(result.success){const downloadUrl=`${API_AUDIO}/download/${result.output_file_id}`;triggerDownload(downloadUrl,targetFilename);}else{throw new Error(result.error||'Server encoding failed');}}catch(transcodeErr){showToast(`Lỗi chuyển đổi: ${transcodeErr.message}. Tải về dạng WAV thay thế.`,"warning");triggerDownload(localUrl,`export_${Date.now()}.wav`);}}else{const finalFilename=fmt==='wav'?`export_${Date.now()}.wav`:`export_${Date.now()}.wav`;if(fmt!=='wav'){showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.","warning");}triggerDownload(localUrl,finalFilename);}return{success:true,trackId:tid,range:parseFloat((rawEnd-rawStart).toFixed(3))+'s',format:fmt,channels:numCh===1?'mono':'stereo'};},selectItem:args=>{if(args.select_all){setSelectedTrackId(null);clearLocalSelection();setSelectionMode('global');setSelectionStart(0);const maxDur=tracks.reduce((max,t)=>{const dur=t.buffer?t.buffer.duration:0;const clips=t.clips||[];const clipMax=clips.reduce((m,c)=>Math.max(m,(c.startTime||0)+(c.buffer?c.buffer.duration:0)),0);return Math.max(max,dur,clipMax);},0);setSelectionEnd(Math.max(maxDur,currentTime+10));return{success:true,selection:'all',duration:parseFloat(Math.max(maxDur,currentTime+10).toFixed(3))};}const tid=args.track_id||selectedTrackId;const track=tracks.find(t=>t.id===tid);if(!track)return{success:false,error:`Track ${tid} not found`};const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,name:track.name,buffer:track.buffer,startTime:track.startTime||0}]:[];if(args.item_name){const match=clips.find(c=>c.name&&c.name.toLowerCase().includes(args.item_name.toLowerCase()));if(!match)return{success:false,error:`No clip matching "${args.item_name}" on track ${track.name}`};setSelectedTrackId(tid);clearLocalSelection();setSelectionMode('global');const start=match.startTime||0;const end=start+(match.buffer?match.buffer.duration:2);setSelectionStart(start);setSelectionEnd(end);return{success:true,trackId:tid,trackName:track.name,clipId:match.id,clipName:match.name,start:parseFloat(start.toFixed(3)),end:parseFloat(end.toFixed(3))};}setSelectedTrackId(tid);clearLocalSelection();setSelectionMode('global');const trackEnd=track.buffer?track.buffer.duration:clips.length>0?Math.max(...clips.map(c=>(c.startTime||0)+(c.buffer?c.buffer.duration:0))):4;setSelectionStart(0);setSelectionEnd(trackEnd);return{success:true,trackId:tid,trackName:track.name,duration:parseFloat(trackEnd.toFixed(3))};},addMarker:args=>{const trackId=args.track_id||selectedTrackId;const time=args.time??currentTime;const track=tracks.find(t=>t.id===trackId);if(!track)return{success:false,error:'Track not found'};setTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,markers:[...(t.markers||[]),{id:'ai_marker_'+Date.now(),time,label:args.label||'AI Marker'}]};}));return{success:true,trackId,time};},fadeIn:args=>{const currentTracks=window.DAWCommandDispatcher?.currentTracks||tracks;const currentSelTrackId=window.DAWCommandDispatcher?.currentSelectedTrackId||selectedTrackId;let trackIdRaw=args.track_id;if(trackIdRaw&&window.DAWCommandDispatcher?.lastCutSourceTrackId&&(String(trackIdRaw)===String(window.DAWCommandDispatcher.lastCutSourceTrackId)||'track_'+trackIdRaw===String(window.DAWCommandDispatcher.lastCutSourceTrackId))){trackIdRaw=window.DAWCommandDispatcher.lastCutNewTrackId;}if(!trackIdRaw)trackIdRaw=currentSelTrackId;const track=currentTracks.find(t=>t.id===String(trackIdRaw)||t.id==='track_'+trackIdRaw);if(!track)return{success:false,error:`Track ${trackIdRaw} not found`};const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];if(clips.length===0)return{success:false,error:'No clips on track'};let clip=null;if(args.clip_id){clip=clips.find(c=>c.id===args.clip_id);}else if(args.clip_index!==undefined){const idx=parseInt(args.clip_index);const realIdx=idx>0?idx-1:0;clip=clips[realIdx]||clips[0];}else{clip=clips[0];}if(!clip||!clip.buffer)return{success:false,error:'Clip has no audio buffer'};const duration=parseFloat(args.duration_seconds||3);const buffer=clip.buffer;const sr=buffer.sampleRate;const numChannels=buffer.numberOfChannels;const length=buffer.length;const ctx=getAudioContext();const newBuffer=ctx.createBuffer(numChannels,length,sr);for(let c=0;c{if(t.id!==track.id)return t;const existingClips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default_'+t.id,buffer:t.buffer,startTime:t.startTime||0,name:t.name}]:[];const updatedClips=existingClips.map(c=>{if(c.id===clip.id||clip.id.startsWith('default_')&&c.id==='default'){return{...c,buffer:newBuffer};}return c;});const mainBuffer=updatedClips[0]?.buffer||t.buffer;return{...t,clips:updatedClips,buffer:mainBuffer};});if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.currentTracks=nextTracks;}setTracks(nextTracks);setTimeout(()=>{const afterSnap=captureTrackSnapshot(track.id);pushAction('AI_FADE_IN',track.id,beforeSnap,afterSnap);},50);return{success:true,trackId:track.id,clipId:clip.id,duration_seconds:duration};},fadeOut:args=>{const currentTracks=window.DAWCommandDispatcher?.currentTracks||tracks;const currentSelTrackId=window.DAWCommandDispatcher?.currentSelectedTrackId||selectedTrackId;let trackIdRaw=args.track_id;if(trackIdRaw&&window.DAWCommandDispatcher?.lastCutSourceTrackId&&(String(trackIdRaw)===String(window.DAWCommandDispatcher.lastCutSourceTrackId)||'track_'+trackIdRaw===String(window.DAWCommandDispatcher.lastCutSourceTrackId))){trackIdRaw=window.DAWCommandDispatcher.lastCutNewTrackId;}if(!trackIdRaw)trackIdRaw=currentSelTrackId;const track=currentTracks.find(t=>t.id===String(trackIdRaw)||t.id==='track_'+trackIdRaw);if(!track)return{success:false,error:`Track ${trackIdRaw} not found`};const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default_'+track.id,buffer:track.buffer,startTime:track.startTime||0,name:track.name}]:[];if(clips.length===0)return{success:false,error:'No clips on track'};let clip=null;if(args.clip_id){clip=clips.find(c=>c.id===args.clip_id);}else if(args.clip_index!==undefined){const idx=parseInt(args.clip_index);const realIdx=idx>0?idx-1:0;clip=clips[realIdx]||clips[0];}else{clip=clips[0];}if(!clip||!clip.buffer)return{success:false,error:'Clip has no audio buffer'};const duration=parseFloat(args.duration_seconds||3);const buffer=clip.buffer;const sr=buffer.sampleRate;const numChannels=buffer.numberOfChannels;const length=buffer.length;const ctx=getAudioContext();const newBuffer=ctx.createBuffer(numChannels,length,sr);for(let c=0;c{if(t.id!==track.id)return t;const existingClips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default_'+t.id,buffer:t.buffer,startTime:t.startTime||0,name:t.name}]:[];const updatedClips=existingClips.map(c=>{if(c.id===clip.id||clip.id.startsWith('default_')&&c.id==='default'){return{...c,buffer:newBuffer};}return c;});const mainBuffer=updatedClips[0]?.buffer||t.buffer;return{...t,clips:updatedClips,buffer:mainBuffer};});if(window.DAWCommandDispatcher){window.DAWCommandDispatcher.currentTracks=nextTracks;}setTracks(nextTracks);setTimeout(()=>{const afterSnap=captureTrackSnapshot(track.id);pushAction('AI_FADE_OUT',track.id,beforeSnap,afterSnap);},50);return{success:true,trackId:track.id,clipId:clip.id,duration_seconds:duration};},generateMultitrackMidi:args=>{const{composition_title,bpm:aiBpm,total_bars,tracks:aiTracks}=args;if(aiBpm){setBpm(aiBpm.toString());}const bpmVal=aiBpm||parseInt(bpm)||120;const secondsPerBeat=60.0/bpmVal;const secondsPerBar=secondsPerBeat*4;const durationSec=total_bars*secondsPerBar;updateActiveTracks(prev=>{let updatedTracks=[...prev];aiTracks.forEach(aiTrack=>{let targetTrack=updatedTracks.find(t=>t.name.toLowerCase()===aiTrack.track_name.toLowerCase());if(!targetTrack){const rearrangeNewId=(updatedTracks.length+1).toString();const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const selectColor=colors[updatedTracks.length%colors.length];targetTrack={id:rearrangeNewId,name:aiTrack.track_name,type:'MIDI',volumeDb:0,pan:0,muted:false,solo:false,color:selectColor,markers:[],serverFileId:null,clips:[],sections:[],midiItems:[],isArmed:false,monitoringEnabled:true,inputSource:{deviceType:'NONE',deviceId:''}};updatedTracks.push(targetTrack);}// Place MIDI item at current playhead position const itemStartTimeSec=currentTime;const newMidiItem={id:'item_ai_'+Date.now()+'_'+Math.random().toString(36).substr(2,5),name:`${composition_title||'AI Theme'} - ${aiTrack.track_name}`,parent_track_id:targetTrack.id,startTime:itemStartTimeSec,duration:durationSec,length_bars:total_bars,notes:aiTrack.notes.map((note,index)=>({id:`note_ai_${Date.now()}_${index}`,pitch:note.pitch,start_beat:note.start_beat,duration_beats:note.duration_beats,velocity:note.velocity||0.8,pan:0.0}))};targetTrack.midiItems=[...(targetTrack.midiItems||[]),newMidiItem];if(aiTrack.soundfont_bank!==undefined&&aiTrack.soundfont_program!==undefined){targetTrack.soundfont_bank=aiTrack.soundfont_bank;targetTrack.soundfont_program=aiTrack.soundfont_program;targetTrack.soundfont_id=aiTrack.soundfont_id||'';const sfEngine={type:'soundfont',plugin_id:aiTrack.soundfont_id?'sf_'+aiTrack.soundfont_id:null,soundfont_bank:aiTrack.soundfont_bank,soundfont_program:aiTrack.soundfont_program,soundfont_id:aiTrack.soundfont_id||''};targetTrack.synth_engine=sfEngine;if(window.SonicSF&&window.SonicSF.applyAITrackInstrument){window.SonicSF.applyAITrackInstrument(aiTrack.soundfont_bank,aiTrack.soundfont_program,sfEngine);}}});return updatedTracks;});showToast(`Đã nạp ${aiTracks.length} tracks MIDI thế hệ AI!`,'success');return{success:true};},rearrangeMidiMelody:args=>{const{rearrange_title,rearranged_notes,soundfont_id,soundfont_bank,soundfont_program}=args;if(!rearranged_notes||rearranged_notes.length===0){return{success:false,error:'No rearranged notes provided'};}const sourceTrackId=window.DAWCommandDispatcher?.rearrangeSourceTrackId||selectedTrackId;const sourceItemName=window.DAWCommandDispatcher?.rearrangeSourceItemName||'Source';const bpmVal=parseInt(bpm)||120;const secondsPerBeat=60.0/bpmVal;const secondsPerBar=secondsPerBeat*4;const totalBeats=rearranged_notes.reduce((max,n)=>Math.max(max,(n.start_beat||0)+(n.duration_beats||1)),0);const totalBars=Math.max(1,Math.ceil(totalBeats/4));const durationSec=totalBars*secondsPerBar;const rearrangeNewId=(activeTracks.length+1).toString();updateActiveTracks(prev=>{let updatedTracks=[...prev];// Find source track index to insert new track right after it -const sourceIdx=sourceTrackId?updatedTracks.findIndex(t=>t.id===sourceTrackId||t.id==='track_'+sourceTrackId):-1;const insertIdx=sourceIdx>=0?sourceIdx+1:updatedTracks.length;const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const newTrack={id:rearrangeNewId,name:`[AI Rearrange] ${rearrange_title||'Variation'}`,type:'MIDI',volumeDb:0,pan:0,muted:false,solo:false,color:colors[insertIdx%colors.length],markers:[],serverFileId:null,clips:[],sections:[],midiItems:[{id:'item_rearr_'+Date.now()+'_'+Math.random().toString(36).substr(2,5),name:`[AI] ${rearrange_title||'Rearranged'} - var`,parent_track_id:rearrangeNewId,startTime:currentTime,duration:durationSec,length_bars:totalBars,notes:rearranged_notes.map((n,i)=>({id:`note_rearr_${Date.now()}_${i}`,pitch:Math.max(0,Math.min(127,n.pitch||60)),start_beat:Math.max(0,parseFloat(n.start_beat)||0),duration_beats:Math.max(0.125,parseFloat(n.duration_beats)||0.25),velocity:Math.max(0.1,Math.min(1.0,n.velocity??0.8)),pan:0.0}))}]};if(soundfont_id||soundfont_bank!==undefined&&soundfont_program!==undefined){newTrack.soundfont_id=soundfont_id||'';newTrack.soundfont_bank=soundfont_bank??0;newTrack.soundfont_program=soundfont_program??0;newTrack.synth_engine={type:'soundfont',plugin_id:soundfont_id?'sf_'+soundfont_id:null,soundfont_bank:soundfont_bank??0,soundfont_program:soundfont_program??0,soundfont_id:soundfont_id||''};}updatedTracks.splice(insertIdx,0,newTrack);return updatedTracks;});showToast(`✅ AI Rearrange: "${rearrange_title}" — ${rearranged_notes.length} notes`,'success');return{success:true,trackId:rearrangeNewId,totalBars};},createMidiItem:args=>{const trackId=args.track_id||selectedTrackId;if(!trackId)return{success:false,error:'No track_id provided'};const secondsPerBar=60.0/(parseInt(bpm)||120)*4;const startBar=args.start_bar!==undefined?parseFloat(args.start_bar):0;const lengthBars=args.length_bars!==undefined?parseFloat(args.length_bars):4;const midiItem={id:`midi_${Date.now()}_${Math.random().toString(36).substr(2,5)}`,name:'MIDI Item',parent_track_id:trackId,startTime:startBar*secondsPerBar,duration:lengthBars*secondsPerBar,notes:[],color:'#a78bfa'};updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId&&t.id!=='track_'+trackId)return t;return{...t,midiItems:[...(t.midiItems||[]),midiItem]};}));return{success:true,itemId:midiItem.id,trackId};},modifyMidiNotes:args=>{const itemId=args.item_id;if(!itemId)return{success:false,error:'No item_id provided'};const noteNameToMidi=name=>{if(typeof name==='number')return name;if(!name||typeof name!=='string')return 60;const match=name.match(/^([A-Ga-g]#?|b?)(-?\d+)$/);if(!match){const num=parseInt(name);return isNaN(num)?60:num;}const noteNames={'c':0,'c#':1,'db':1,'d':2,'d#':3,'eb':3,'e':4,'f':5,'f#':6,'gb':6,'g':7,'g#':8,'ab':8,'a':9,'a#':10,'bb':10,'b':11};const key=match[1].toLowerCase();const octave=parseInt(match[2]);const base=noteNames[key]!==undefined?noteNames[key]:0;return(octave+1)*12+base;};const newNotes=(args.notes||[]).map((n,index)=>({id:`note_mod_${Date.now()}_${index}`,pitch:noteNameToMidi(n.pitch),start_beat:parseFloat(n.start_time||0),duration_beats:parseFloat(n.duration||1),velocity:n.velocity!==undefined?n.velocity/127.0:0.8,pan:0.0}));updateActiveTracks(prev=>prev.map(t=>{const items=t.midiItems||[];const exists=items.some(m=>m.id===itemId);if(!exists)return t;return{...t,midiItems:items.map(m=>m.id===itemId?{...m,notes:newNotes}:m)};}));return{success:true,itemId};}};window.DAWCommandDispatcher.registerDAWCommands(api);},[tracks,selectedTrackId,currentTime,bpm]);// ── Save AI config to localStorage ── +const sourceIdx=sourceTrackId?updatedTracks.findIndex(t=>t.id===sourceTrackId||t.id==='track_'+sourceTrackId):-1;const insertIdx=sourceIdx>=0?sourceIdx+1:updatedTracks.length;const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const newTrack={id:rearrangeNewId,name:`[AI Rearrange] ${rearrange_title||'Variation'}`,type:'MIDI',volumeDb:0,pan:0,muted:false,solo:false,color:colors[insertIdx%colors.length],markers:[],serverFileId:null,clips:[],sections:[],midiItems:[{id:'item_rearr_'+Date.now()+'_'+Math.random().toString(36).substr(2,5),name:`[AI] ${rearrange_title||'Rearranged'} - var`,parent_track_id:rearrangeNewId,startTime:currentTime,duration:durationSec,length_bars:totalBars,notes:rearranged_notes.map((n,i)=>({id:`note_rearr_${Date.now()}_${i}`,pitch:Math.max(0,Math.min(127,n.pitch||60)),start_beat:Math.max(0,parseFloat(n.start_beat)||0),duration_beats:Math.max(0.125,parseFloat(n.duration_beats)||0.25),velocity:Math.max(0.1,Math.min(1.0,n.velocity??0.8)),pan:0.0}))}]};if(soundfont_id||soundfont_bank!==undefined&&soundfont_program!==undefined){newTrack.soundfont_id=soundfont_id||'';newTrack.soundfont_bank=soundfont_bank??0;newTrack.soundfont_program=soundfont_program??0;newTrack.synth_engine={type:'soundfont',plugin_id:soundfont_id?'sf_'+soundfont_id:null,soundfont_bank:soundfont_bank??0,soundfont_program:soundfont_program??0,soundfont_id:soundfont_id||''};}updatedTracks.splice(insertIdx,0,newTrack);return updatedTracks;});showToast(`✅ AI Rearrange: "${rearrange_title}" — ${rearranged_notes.length} notes`,'success');return{success:true,trackId:rearrangeNewId,totalBars};},createMidiItem:args=>{const trackId=String(args.track_id||selectedTrackId);if(!trackId)return{success:false,error:'No track_id provided'};const secondsPerBar=60.0/(parseInt(bpm)||120)*4;const startBar=args.start_bar!==undefined?parseFloat(args.start_bar):0;const lengthBars=args.length_bars!==undefined?parseFloat(args.length_bars):4;const midiItem={id:`midi_${Date.now()}_${Math.random().toString(36).substr(2,5)}`,name:'MIDI Item',parent_track_id:trackId,startTime:startBar*secondsPerBar,duration:lengthBars*secondsPerBar,length_bars:lengthBars,notes:[],color:'#a78bfa'};updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId&&t.id!=='track_'+trackId)return t;return{...t,midiItems:[...(t.midiItems||[]),midiItem]};}));return{success:true,itemId:midiItem.id,trackId};},modifyMidiNotes:args=>{const itemId=args.item_id;if(!itemId)return{success:false,error:'No item_id provided'};const noteNameToMidi=name=>{if(typeof name==='number')return name;if(!name||typeof name!=='string')return 60;const match=name.match(/^([A-Ga-g]#?|b?)(-?\d+)$/);if(!match){const num=parseInt(name);return isNaN(num)?60:num;}const noteNames={'c':0,'c#':1,'db':1,'d':2,'d#':3,'eb':3,'e':4,'f':5,'f#':6,'gb':6,'g':7,'g#':8,'ab':8,'a':9,'a#':10,'bb':10,'b':11};const key=match[1].toLowerCase();const octave=parseInt(match[2]);const base=noteNames[key]!==undefined?noteNames[key]:0;return(octave+1)*12+base;};const newNotes=(args.notes||[]).map((n,index)=>({id:`note_mod_${Date.now()}_${index}`,pitch:noteNameToMidi(n.pitch),start_beat:parseFloat(n.start_time||0),duration_beats:parseFloat(n.duration||1),velocity:n.velocity!==undefined?n.velocity/127.0:0.8,pan:0.0}));updateActiveTracks(prev=>prev.map(t=>{const items=t.midiItems||[];const exists=items.some(m=>m.id===itemId);if(!exists)return t;return{...t,midiItems:items.map(m=>m.id===itemId?{...m,notes:newNotes}:m)};}));return{success:true,itemId};}};window.DAWCommandDispatcher.registerDAWCommands(api);},[tracks,selectedTrackId,currentTime,bpm]);// ── Save AI config to localStorage ── useEffect(()=>{localStorage.setItem('ai_base_url',aiConfig.baseUrl);localStorage.setItem('ai_api_key',aiConfig.apiKey);localStorage.setItem('ai_model',aiConfig.model);},[aiConfig]);// ── Auto-save user preferences (panel state, provider) ── const prefsRef=useRef({});prefsRef.current={showAIPanel,showExportPanel,showSelectionPanel,showPythonToolsPanel,showMediaExplorer,showFxRack,showMidiEvents,panelPositions,rightSidebarWidth,mediaExplorerHeight,selectedProviderId};useEffect(()=>{const prefs=prefsRef.current;localStorage.setItem('sonic_preferences',JSON.stringify(prefs));if(!currentUser||currentUser==='cached')return;const timer=setTimeout(async()=>{try{await window.SonicAPI.savePreferences(prefs);}catch(e){}},2000);return()=>clearTimeout(timer);},[showAIPanel,showExportPanel,showSelectionPanel,showPythonToolsPanel,showMediaExplorer,showFxRack,showMidiEvents,panelPositions,rightSidebarWidth,mediaExplorerHeight,selectedProviderId,currentUser]);const drawMixerVuMeter=(canvas,peak)=>{if(!canvas)return;const ctx=canvas.getContext('2d');if(!ctx)return;const w=canvas.width;const h=canvas.height;ctx.clearRect(0,0,w,h);ctx.fillStyle='#0d0d0d';ctx.fillRect(0,0,w,h);const barH=peak*h;const grad=ctx.createLinearGradient(0,h,0,0);grad.addColorStop(0,'#22c55e');grad.addColorStop(0.5,'#eab308');grad.addColorStop(0.85,'#ef4444');ctx.fillStyle=grad;ctx.fillRect(0,h-barH,w,barH);};// Master & Track VU meter animation loop const masterVUAnimRef=useRef(null);useEffect(()=>{function tick(){try{// Khi STOP (không play, không recording): vẽ VU RỖNG — không đọc analyser/ diff --git a/app/static/js/services/api.js b/app/static/js/services/api.js index f870121..104bd45 100644 --- a/app/static/js/services/api.js +++ b/app/static/js/services/api.js @@ -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' }), 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' }), + 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 }) }), listCloudProjects: () => apiRequest('/api/v1/projects/cloud', { method: 'GET' }), getCloudProject: (projectId) => apiRequest(`/api/v1/projects/cloud/${projectId}`, { method: 'GET' }), diff --git a/app/static/js/services/storage.js b/app/static/js/services/storage.js index eec6af0..d85e1d6 100644 --- a/app/static/js/services/storage.js +++ b/app/static/js/services/storage.js @@ -61,6 +61,21 @@ let autoSaveTimer = 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) { if (getProjectStateCallback) lastGetProjectStateCallback = getProjectStateCallback; if (autoSaveTimer) clearTimeout(autoSaveTimer); @@ -69,9 +84,11 @@ const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null; if (!state || (!state.tracks && !state.main_session)) return; const dataJson = JSON.stringify(state); + if (dataJson === lastSavedJson) return; // không đổi → không ghi + lastSavedJson = dataJson; localStorage.setItem('sonic_temp_project', dataJson); if (window.SonicAPI && localStorage.getItem('sonic_token')) { - await window.SonicAPI.saveTempProject(dataJson).catch(() => {}); + await window.SonicAPI.saveTempProject(dataJson, getClientId()).catch(() => {}); } } catch (e) { console.warn("Auto-save temp project warning:", e); @@ -85,9 +102,11 @@ const state = lastGetProjectStateCallback ? lastGetProjectStateCallback() : null; if (!state || (!state.tracks && !state.main_session)) return; const dataJson = JSON.stringify(state); + if (dataJson === lastSavedJson) return; // không đổi → không ghi + lastSavedJson = dataJson; localStorage.setItem('sonic_temp_project', dataJson); if (window.SonicAPI && localStorage.getItem('sonic_token')) { - await window.SonicAPI.saveTempProject(dataJson).catch(() => {}); + await window.SonicAPI.saveTempProject(dataJson, getClientId()).catch(() => {}); } } catch (e) { console.warn("Flush temp project warning:", e); @@ -98,6 +117,7 @@ exportProjectToSFS, importProjectFromSFSFile, scheduleTempAutoSave, - flushTempAutoSave + flushTempAutoSave, + getClientId }; })(); diff --git a/app/templates/index.html b/app/templates/index.html index 3a8071a..c2e97d0 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -46,7 +46,7 @@ - +