diff --git a/app/api/v1/files.py b/app/api/v1/files.py new file mode 100644 index 0000000..0cfa60b --- /dev/null +++ b/app/api/v1/files.py @@ -0,0 +1,128 @@ +import os, sys, json, subprocess, time as _time +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from pydantic import BaseModel +from typing import Optional + +router = APIRouter() + +# ── Native "Save As" dialog (fix bug: export MIDI/WAV không hỏi nơi lưu) ── +# WebView2 standalone chặn anchor-download → không thấy save dialog. Giải pháp: +# frontend gọi /save-dialog → engine mở NATIVE SaveFileDialog (Windows Explorer) +# → trả đường dẫn user chọn → frontend gửi bytes qua /write → ghi file thật. +SAVE_DIALOG_TIMEOUT = 120 # user có thể để dialog mở lâu + + +def _save_file_via_tauri_bridge(default_name: str) -> Optional[str]: + """Tauri shell (Rust watcher trong lib.rs) mở NATIVE SaveFileDialog qua + file IPC: engine ghi save_file.request (chứa tên mặc định) → Rust mở dialog + → ghi save_file.response. Trả None nếu bridge không tồn tại (standalone).""" + root = os.environ.get("APPDATA") or os.path.expanduser("~") + ipc = os.path.join(root, "SonicForgeDAW", "ipc") + if not os.path.isdir(ipc): + return None + # Bridge chi dung khi engine do Tauri spawn (co SF_PARENT_PID). Marker co + # the con sot tu lan Tauri chay truoc → chay standalone se cho response + # khong bao gio den (treo 120s). Check nay loai bo truong hop do. + if not os.environ.get("SF_PARENT_PID"): + return None + if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")): + return None + req = os.path.join(ipc, "save_file.request") + resp = os.path.join(ipc, "save_file.response") + try: + for f in (req, resp): + if os.path.exists(f): + os.remove(f) + with open(req, "w", encoding="utf-8") as fh: + fh.write(default_name or "untitled") + deadline = _time.time() + SAVE_DIALOG_TIMEOUT + while _time.time() < deadline: + if os.path.exists(resp): + try: + with open(resp, "r", encoding="utf-8") as fh: + val = fh.read().strip() + finally: + os.remove(resp) + return val or None + _time.sleep(0.1) + except Exception: + pass + return None + + +def _save_file_native_engine(default_name: str, filter_name: str, filter_ext: str) -> Optional[str]: + """Fallback khi không có Tauri bridge: PowerShell SaveFileDialog (Windows), + osascript (macOS), zenity/kdialog (Linux).""" + if os.name == "nt": + # FileName mặc định + Filter — single-quoted trong PS, filename không + # chứa nháy đơn (SonicForge tự sinh tên). + ps = ( + "Add-Type -AssemblyName System.Windows.Forms; " + f"$f = New-Object System.Windows.Forms.SaveFileDialog; " + f"$f.FileName = '{default_name}'; " + f"$f.Filter = '{filter_name} (*.{filter_ext})|*.{filter_ext}|Tất cả các tệp (*.*)|*.*'; " + "$f.Title = 'Chọn nơi lưu tệp xuất'; " + "if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.FileName }" + ) + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-STA", "-Command", ps], + capture_output=True, text=True, timeout=SAVE_DIALOG_TIMEOUT, + ) + return r.stdout.strip() or None + except Exception: + return None + if sys.platform == "darwin": + try: + r = subprocess.run( + ["osascript", "-e", + 'set f to choose file name with prompt "Chọn nơi lưu tệp xuất" default name "' + (default_name or "untitled") + '"'], + capture_output=True, text=True, timeout=SAVE_DIALOG_TIMEOUT, + ) + return r.stdout.strip() or None + except Exception: + return None + for cmd in (["zenity", "--file-selection", "--save", "--filename=" + (default_name or "untitled")], + ["kdialog", "--getsavefilename", os.path.join(os.path.expanduser("~"), default_name or "untitled")]): + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=SAVE_DIALOG_TIMEOUT) + if r.returncode == 0: + p = r.stdout.strip() + if p: + return p + except Exception: + continue + return None + + +class SaveDialogRequest(BaseModel): + default_name: str = "untitled" + filter_name: str = "File" + filter_ext: str = "bin" + + +@router.post("/save-dialog") +def save_file_dialog(req: SaveDialogRequest): + """Mở NATIVE Save As dialog. Không cần auth (desktop local, chỉ mở dialog). + Trả {"path": "<đường dẫn user chọn>"} hoặc {"path": None} (hủy/không có + dialog). def (sync) → FastAPI chạy threadpool — không block loop.""" + path = _save_file_via_tauri_bridge(req.default_name) + if path is None: + path = _save_file_native_engine(req.default_name, req.filter_name, req.filter_ext) + return {"path": path} + + +@router.post("/write") +async def write_export_file(path: str = Form(...), file: UploadFile = File(...)): + """Ghi bytes đã export vào đường dẫn tuyệt đối user chọn qua save dialog. + Trả {"path": ..., "bytes": N} — frontend toast kèm đường dẫn thật.""" + try: + data = await file.read() + dirname = os.path.dirname(path) + if dirname: + os.makedirs(dirname, exist_ok=True) + with open(path, "wb") as f: + f.write(data) + return {"path": path, "bytes": len(data)} + except Exception as e: + raise HTTPException(status_code=400, detail=f"Không thể ghi file: {str(e)}") diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index 71dcadf..0d66de7 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -277,6 +277,11 @@ def _pick_dir_via_tauri_bridge() -> Optional[str]: ipc = os.path.join(root, "SonicForgeDAW", "ipc") if not os.path.isdir(ipc): return None + # Bridge chi dung khi engine do Tauri spawn (co SF_PARENT_PID). Marker co + # the con sot tu lan Tauri chay truoc → chay standalone se cho response + # khong bao gio den (treo 120s). Check nay loai bo truong hop do. + if not os.environ.get("SF_PARENT_PID"): + return None # Marker do Rust viết lúc setup — bridge chỉ có trong app Tauri desktop if not os.path.exists(os.path.join(ipc, "tauri_bridge_ready")): return None diff --git a/app/main.py b/app/main.py index ef4f56e..8f0a2df 100644 --- a/app/main.py +++ b/app/main.py @@ -17,6 +17,7 @@ from app.api.v1.user_config import router as user_config_router from app.api.v1.ai_proxy import router as ai_proxy_router from app.api.v1.ai_presets import router as ai_presets_router from app.api.v1.plugins import router as plugins_router +from app.api.v1.files import router as files_router from app.api.v1.media import router as media_router from app.api.v1.system import router as system_router from app.api.v1.presets import router as presets_router @@ -109,6 +110,7 @@ app.include_router(user_config_router, prefix="/api/v1/user", tags=["user_config app.include_router(ai_proxy_router, prefix="/api/v1/ai", tags=["ai"]) app.include_router(ai_presets_router, prefix="/api/v1/ai", tags=["ai"]) app.include_router(plugins_router, prefix="/api/v1/plugins", tags=["plugins"]) +app.include_router(files_router, prefix="/api/v1/files", tags=["files"]) app.include_router(media_router, prefix="/api/v1/media", tags=["media"]) app.include_router(system_router, prefix="/api/v1/system", tags=["system"]) app.include_router(presets_router, prefix="/api/v1/presets", tags=["presets"]) diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index caaeb8d..48c3219 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -11029,6 +11029,12 @@ const serializeTracksList = (tracksList, secondsPerBar) => { } if (t.sections && t.sections.length > 0) { t.sections.forEach(s => { + // BỎ section item thiếu sectionId (insertSectionAtPlayhead tạo section + // chưa gắn vào tab → s.sectionId undefined). Nếu serialize thật thì + // referenced_section_id bị JSON.stringify bỏ → source_data = {} → + // schema oneOf fail → lưu project báo lỗi xác thực (bug user gặp). + // Item vô nghĩa (chưa có nội dung tab) — bỏ là đúng, không mất data. + if (!s.sectionId) return; items.push({ id: s.id, name: s.name || "Section Item", @@ -11037,9 +11043,6 @@ const serializeTracksList = (tracksList, secondsPerBar) => { duration_bars: s.duration / secondsPerBar, clip_start_offset_bars: 0.0, source_data: { - // KHÔNG fallback s.id: section item thiếu sectionId (insert thiếu - // field) → fallback = chính id item → TỰ TRỎ → block lồng + mất - // nội dung. Chỉ dùng sectionId; undefined → deserialize block rỗng. referenced_section_id: s.sectionId } }); @@ -26593,6 +26596,36 @@ const App = () => { })); }; + // ── Save-As qua NATIVE dialog (fix bug: export không hỏi nơi lưu) ── + // WebView2 chặn anchor-download → engine mở SaveFileDialog qua + // /api/v1/files/save-dialog (PowerShell hoặc Tauri bridge) → ghi bytes qua + // /api/v1/files/write. Trả path đã ghi; null nếu user hủy / không có dialog. + async function saveExportViaDialog(defaultName, blob) { + try { + const dlg = await fetch(`${API_BASE_URL}/api/v1/files/save-dialog`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + default_name: defaultName, + filter_name: defaultName.toLowerCase().endsWith('.mid') ? 'MIDI' : 'Audio', + filter_ext: defaultName.split('.').pop() || 'bin' + }) + }); + if (!dlg.ok) return null; + const d = await dlg.json(); + if (!d.path) return null; + const fd = new FormData(); + fd.append('path', d.path); + fd.append('file', new File([blob], defaultName, { type: blob.type || 'application/octet-stream' })); + const wr = await fetch(`${API_BASE_URL}/api/v1/files/write`, { method: 'POST', body: fd }); + if (!wr.ok) return null; + const w = await wr.json(); + return w.path; + } catch (e) { + return null; + } + } + // ── MIDI Export: tạo file MIDI từ tất cả tracks ── // ── Server-side MIDI export (BIG_PHASE_3 Phase 1) ── // Blob download bị WebView2 chặn sau khi chuyển standalone → POST @@ -26610,7 +26643,18 @@ const App = () => { throw new Error(e.detail || ('HTTP ' + res.status)); } return res.json(); - }).then(data => { + }).then(async data => { + // NATIVE Save-As dialog (WebView2 chặn anchor download) → ghi file thật. + try { + const blobResp = await fetch(`${API_BASE_URL}${data.download_url}`); + if (blobResp.ok) { + const blob = await blobResp.blob(); + const savedPath = await saveExportViaDialog(filename, blob); + if (savedPath) { + return { ...data, saved_path: savedPath }; + } + } + } catch (e) { /* fallback anchor dưới */ } const a = document.createElement('a'); a.href = `${API_BASE_URL}${data.download_url}`; a.download = filename; @@ -26627,6 +26671,11 @@ const App = () => { // /api/v1/audio/download/{file_id} (Content-Disposition attachment). // Nếu upload thất bại (server offline) → fallback blob download cũ. async function downloadBlobViaServer(blob, filename) { + // NATIVE Save-As dialog trước (WebView2 chặn anchor download) → ghi file thật. + try { + const savedPath = await saveExportViaDialog(filename, blob); + if (savedPath) return { saved_path: savedPath }; + } catch (e) { /* fallback upload+anchor dưới */ } try { const fd = new FormData(); fd.append('file', new File([blob], filename, { type: blob.type || 'audio/wav' })); @@ -26677,8 +26726,12 @@ const App = () => { return; } try { - await serverDownloadMidi({ bpm: bpmNum, ppq: 480, tracks: midiTracks }, (projectName || 'Project') + '.mid'); - showToast(`Đã xuất file MIDI với ${midiTracks.length} tracks!`, "success"); + const r = await serverDownloadMidi({ bpm: bpmNum, ppq: 480, tracks: midiTracks }, (projectName || 'Project') + '.mid'); + if (r && r.saved_path) { + showToast(`Đã xuất file MIDI (${midiTracks.length} tracks) → ${r.saved_path}`, "success"); + } else { + showToast(`Đã xuất file MIDI với ${midiTracks.length} tracks!`, "success"); + } } catch (err) { showToast('Export MIDI thất bại: ' + (err && err.message ? err.message : String(err)), 'error'); } @@ -27008,11 +27061,19 @@ const App = () => { const fileId = result.output_file_id || result.output_path?.split('/').pop(); if (fileId) { const downloadUrl = `${API_AUDIO}/download/${fileId}`; - showToast("Xuất bản âm thanh từ máy chủ hoàn tất!", "success", "Tải về", () => { - const a = document.createElement('a'); - a.href = downloadUrl; - a.download = fileId; - a.click(); + showToast("Xuất bản âm thanh từ máy chủ hoàn tất!", "success", "Tải về", async () => { + try { + const br = await fetch(downloadUrl); + if (!br.ok) throw new Error('HTTP ' + br.status); + const b = await br.blob(); + const sp = await saveExportViaDialog((projectName || 'Project') + '.wav', b); + if (sp) showToast(`Đã lưu → ${sp}`, "success"); + } catch (e) { + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = fileId; + a.click(); + } }); } } else { @@ -27495,11 +27556,19 @@ const App = () => { if (result.success) { const outId = result.output_file_id; const downloadUrl = `${API_AUDIO}/download/${outId}`; - showToast(`Xuất bản âm thanh định dạng ${exportSettings.format.toUpperCase()} thành công!`, "success", "Tải về", () => { - const a = document.createElement('a'); - a.href = downloadUrl; - a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`; - a.click(); + showToast(`Xuất bản âm thanh định dạng ${exportSettings.format.toUpperCase()} thành công!`, "success", "Tải về", async () => { + try { + const br = await fetch(downloadUrl); + if (!br.ok) throw new Error('HTTP ' + br.status); + const b = await br.blob(); + const sp = await saveExportViaDialog(`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`, b); + if (sp) showToast(`Đã lưu → ${sp}`, "success"); + } catch (e) { + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = `sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`; + a.click(); + } }); } else { throw new Error(result.error || 'Server encoding failed'); @@ -27513,8 +27582,12 @@ const App = () => { if (exportSettings.format !== 'wav') { showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.", "warning"); } - await downloadBlobViaServer(blob, wavName).catch(() => {}); - showToast("Xuất bản âm thanh hoàn tất!", "success"); + const dres = await downloadBlobViaServer(blob, wavName).catch(() => null); + if (dres && dres.saved_path) { + showToast(`Xuất bản âm thanh hoàn tất → ${dres.saved_path}`, "success"); + } else { + showToast("Xuất bản âm thanh hoàn tất!", "success"); + } } } finally { // Restore the live master bus + live mastering state after offline render. diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 8622bb5..da6a05e 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -706,10 +706,12 @@ if(window.__setPrHint){window.__setPrHint(prKeyStateRef.current.ctrl?"Ctrl+drag: handleSwitchMidiItem(m.id);},className:"flex items-center justify-center flex-1 h-[26px] min-w-0 border border-zinc-600 rounded-md cursor-pointer outline-none "+(isActive?'bg-yellow-600 text-black font-bold':'bg-zinc-700 text-zinc-300 hover:bg-zinc-600')},React.createElement("span",{className:"text-[13px] leading-none font-sans truncate px-1",title:track?track.name:m._trackName},track?track.name:m._trackName)),React.createElement("button",{onClick:function(e){e.stopPropagation();var prevList=activePlayTrackIds||[];var nextList=prevList.indexOf(m._trackId)!==-1?prevList.filter(function(id){return id!==m._trackId;}):prevList.concat([m._trackId]);setActivePlayTrackIds(nextList);if(onRealtimePlay)onRealtimePlay(nextList);},title:isPlayOn?"Unmute — ghost track play cùng main notes":"Mute (mặc định) — click để play ghost cùng main",className:"w-6 h-[26px] shrink-0 border border-zinc-600 rounded-md cursor-pointer text-[11px] font-bold outline-none flex items-center justify-center "+(isPlayOn?'bg-green-700 text-white':'bg-zinc-800 text-zinc-500 hover:text-zinc-300')},isPlayOn?"\u266A":"M")));});return els;}():null),React.createElement("div",{className:"w-[60px] shrink-0 flex flex-col border-r border-zinc-900 overflow-y-auto",ref:keybedRef,onScroll:handleKeybedScroll,style:{scrollbarWidth:'none',msOverflowStyle:'none'}},renderKeybed()),React.createElement("div",{ref:gridScrollRef,onScroll:handleScroll,className:"flex-1 overflow-auto bg-[#141414] min-w-0"},React.createElement("div",{style:{width:`${viewWidth}px`,height:`${(128-PITCH_START)*NoteHeight}px`},className:"relative"},React.createElement("canvas",{ref:canvasRef,onMouseDown:handleGridMouseDown,onMouseMove:handleGridMouseMove,onMouseUp:handleGridMouseUp,onMouseLeave:handleGridMouseUp,onContextMenu:handleContextMenu,className:"absolute inset-0 cursor-crosshair"}),loopStartBeat!==null&&loopEndBeat!==null&&loopEndBeat>loopStartBeat&&React.createElement("div",{style:{left:`${loopStartBeat*pixelsPerBeat}px`,width:`${(loopEndBeat-loopStartBeat)*pixelsPerBeat}px`,top:0,bottom:0},className:"absolute bg-emerald-500/10 border-l border-r border-emerald-400/50 pointer-events-none"})))),/* 4. CC / VELOCITY LANE (ĐẶT Ở ĐÁY) */showCC&&React.createElement("div",{style:{height:`${ccHeight}px`},className:"bg-[#161616] border-t border-zinc-900 flex shrink-0 relative"},React.createElement("div",{onMouseDown:e=>{e.preventDefault();const startY=e.clientY;const startH=ccHeight;const onMove=ev=>{setCcHeight(Math.max(40,startH+startY-ev.clientY));};const onUp=()=>{document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);};document.addEventListener('mousemove',onMove);document.addEventListener('mouseup',onUp);},className:"absolute top-0 left-0 right-0 h-1.5 cursor-n-resize z-10 hover:bg-purple-600/30"}),React.createElement("div",{className:"w-[120px] shrink-0"}),React.createElement("div",{className:"w-[60px] bg-[#222222] border-r border-zinc-900 flex items-center justify-center text-[10px] text-zinc-500 font-mono shrink-0 font-bold"},ccMode.toUpperCase()),React.createElement("div",{ref:ccWrapperRef,className:"flex-1 overflow-x-hidden min-w-0"},React.createElement("div",{style:{width:`${viewWidth}px`,height:'100%'},className:"relative"},React.createElement("canvas",{ref:ccCanvasRef,onMouseDown:handleCCMouseDown,onMouseMove:handleCCMouseMove,onMouseUp:()=>{if(ccDragRef.current)ccDragRef.current.selectedMode=false;ccDragRef.current=null;},onMouseLeave:()=>{if(ccDragRef.current)ccDragRef.current.selectedMode=false;ccDragRef.current=null;},className:"absolute inset-0"}),loopStartBeat!==null&&loopEndBeat!==null&&loopEndBeat>loopStartBeat&&React.createElement("div",{style:{left:`${loopStartBeat*pixelsPerBeat}px`,width:`${(loopEndBeat-loopStartBeat)*pixelsPerBeat}px`,top:0,bottom:0},className:"absolute bg-emerald-500/10 border-l border-r border-emerald-400/50 pointer-events-none"})))),/* 5. OVERLAY / CONTEXT MENU */arpModal&&React.createElement("div",{className:"absolute inset-0 z-40 flex items-center justify-center bg-black/60",onMouseDown:e=>e.stopPropagation(),onClick:e=>e.stopPropagation()},React.createElement("div",{className:"bg-zinc-900 border border-zinc-700 rounded-lg p-4 w-72 shadow-2xl"},React.createElement("div",{className:"text-sm font-bold text-cyan-300 mb-3"},"Arpeggiate"),React.createElement("div",{className:"grid grid-cols-2 gap-2 text-xs mb-3"},React.createElement("label",{className:"text-zinc-500"},"Pattern"),React.createElement("select",{value:arpModal.pattern,onChange:e=>setArpModal({...arpModal,pattern:e.target.value}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5"},['UP','DOWN','UP-DOWN','RANDOM','CHORD'].map(p=>React.createElement("option",{key:p,value:p},p))),React.createElement("label",{className:"text-zinc-500"},"Rate"),React.createElement("select",{value:arpModal.rate,onChange:e=>setArpModal({...arpModal,rate:e.target.value}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5"},['1/4','1/8','1/16','1/32'].map(r=>React.createElement("option",{key:r,value:r},r))),React.createElement("label",{className:"text-zinc-500"},"Octaves"),React.createElement("input",{type:"number",min:1,max:4,value:arpModal.octaves,onChange:e=>setArpModal({...arpModal,octaves:parseInt(e.target.value)||1}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-14"}),React.createElement("label",{className:"text-zinc-500"},"Gate %"),React.createElement("input",{type:"number",min:10,max:200,value:arpModal.gate,onChange:e=>setArpModal({...arpModal,gate:parseInt(e.target.value)||80}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-14"})),React.createElement("div",{className:"flex gap-1 mb-3"},React.createElement("label",{className:"flex items-center gap-1 text-[10px] text-zinc-400"},React.createElement("input",{type:"checkbox",checked:!!arpModal.triplet,onChange:e=>setArpModal({...arpModal,triplet:e.target.checked})}),"Triplet"),React.createElement("label",{className:"flex items-center gap-1 text-[10px] text-zinc-400"},React.createElement("input",{type:"checkbox",checked:!!arpModal.dotted,onChange:e=>setArpModal({...arpModal,dotted:e.target.checked})}),"Dotted")),React.createElement("div",{className:"flex gap-2"},React.createElement("button",{onClick:()=>applyArpeggiate(arpModal),className:"flex-1 px-3 py-1.5 rounded text-xs font-bold bg-cyan-700 hover:bg-cyan-600 text-white"},"Apply"),React.createElement("button",{onClick:()=>setArpModal(null),className:"px-3 py-1.5 rounded text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-300"},"Cancel")))),strumModal&&React.createElement("div",{className:"absolute inset-0 z-40 flex items-center justify-center bg-black/60",onMouseDown:e=>e.stopPropagation(),onClick:e=>e.stopPropagation()},React.createElement("div",{className:"bg-zinc-900 border border-zinc-700 rounded-lg p-4 w-72 shadow-2xl"},React.createElement("div",{className:"text-sm font-bold text-cyan-300 mb-3"},"Strum"),React.createElement("div",{className:"grid grid-cols-2 gap-2 text-xs mb-3"},React.createElement("label",{className:"text-zinc-500"},"Strum ms"),React.createElement("input",{type:"number",min:0,max:120,value:strumModal.ms,onChange:e=>setStrumModal({...strumModal,ms:parseInt(e.target.value)||0}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-16"}),React.createElement("label",{className:"text-zinc-500"},"Direction"),React.createElement("select",{value:strumModal.direction,onChange:e=>setStrumModal({...strumModal,direction:e.target.value}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5"},['DOWN','UP','ALTERNATE'].map(d=>React.createElement("option",{key:d,value:d},d)))),React.createElement("div",{className:"flex gap-2"},React.createElement("button",{onClick:()=>applyStrum(strumModal),className:"flex-1 px-3 py-1.5 rounded text-xs font-bold bg-cyan-700 hover:bg-cyan-600 text-white"},"Apply"),React.createElement("button",{onClick:()=>setStrumModal(null),className:"px-3 py-1.5 rounded text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-300"},"Cancel")))),humanizeModal&&React.createElement("div",{className:"absolute inset-0 z-40 flex items-center justify-center bg-black/60",onMouseDown:e=>e.stopPropagation(),onClick:e=>e.stopPropagation()},React.createElement("div",{className:"bg-zinc-900 border border-zinc-700 rounded-lg p-4 w-72 shadow-2xl"},React.createElement("div",{className:"text-sm font-bold text-cyan-300 mb-3"},"Humanize"),React.createElement("div",{className:"grid grid-cols-2 gap-2 text-xs mb-3"},React.createElement("label",{className:"text-zinc-500"},"Timing ±ms"),React.createElement("input",{type:"number",min:0,max:30,value:humanizeModal.timingMs,onChange:e=>setHumanizeModal({...humanizeModal,timingMs:parseInt(e.target.value)||0}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-16"}),React.createElement("label",{className:"text-zinc-500"},"Vel ±(0-127)"),React.createElement("input",{type:"number",min:0,max:20,value:humanizeModal.velRange,onChange:e=>setHumanizeModal({...humanizeModal,velRange:parseInt(e.target.value)||0}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-16"}),React.createElement("label",{className:"text-zinc-500"},"Dur ±%"),React.createElement("input",{type:"number",min:0,max:50,value:humanizeModal.durRange,onChange:e=>setHumanizeModal({...humanizeModal,durRange:parseInt(e.target.value)||0}),className:"bg-zinc-800 border border-zinc-700 text-zinc-200 rounded px-1 py-0.5 w-16"})),React.createElement("div",{className:"flex gap-2"},React.createElement("button",{onClick:()=>applyHumanizeModal(humanizeModal),className:"flex-1 px-3 py-1.5 rounded text-xs font-bold bg-cyan-700 hover:bg-cyan-600 text-white"},"Apply"),React.createElement("button",{onClick:()=>setHumanizeModal(null),className:"px-3 py-1.5 rounded text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-300"},"Cancel")))),scaleMenuPos&&renderScaleContextMenu());};const serializeTracksList=(tracksList,secondsPerBar)=>{return(tracksList||[]).map(t=>{let trackType="AUDIO";if(t.type==='MIDI'||t.type==='soundfont'||t.type==='vst3')trackType="MIDI";else if(t.type==='SECTION')trackType="SECTION";else if(t.sections&&t.sections.length>0)trackType="SECTION";else if(t.midiItems&&t.midiItems.length>0)trackType="MIDI";const items=[];// Serialize EVERY item type present on the track (a track can hold audio // clips + MIDI items + section items at once). The old if/else-if chain // dropped all but one type per track — silent data loss on save. -if(t.clips&&t.clips.length>0){t.clips.forEach(c=>{const durationSec=c.buffer?c.buffer.duration:4.0;const clipFileId=c.serverFileId||t.serverFileId;items.push({id:c.id,name:c.name||"Audio Clip",type:"AUDIO_ITEM",start_bar:c.startTime/secondsPerBar,duration_bars:durationSec/(c.speed||1.0)/secondsPerBar,clip_start_offset_bars:0.0,source_data:{audio_file_url:clipFileId?`/static/audio/uploads/${clipFileId}`:"",sample_rate:c.buffer?c.buffer.sampleRate:44100,channels:c.buffer?c.buffer.numberOfChannels:2,gain:1.0,server_file_id:clipFileId}});});}if(t.midiItems&&t.midiItems.length>0){t.midiItems.forEach(m=>{items.push({id:m.id,name:m.name||"MIDI Item",type:"MIDI_ITEM",start_bar:m.startTime/secondsPerBar,duration_bars:m.duration?m.duration/secondsPerBar:4.0,clip_start_offset_bars:0.0,source_data:{total_buffer_bars:m.duration?m.duration/secondsPerBar:8.0,notes:(m.notes||[]).map(n=>({id:n.id||'note_'+Math.random().toString(36).substr(2,9),pitch:n.pitch||60,start_beat:n.start_beat||0.0,duration_beats:n.duration_beats||1.0,velocity:n.velocity||0.8,pan:n.pan||0.0}))}});});}if(t.sections&&t.sections.length>0){t.sections.forEach(s=>{items.push({id:s.id,name:s.name||"Section Item",type:"SECTION_ITEM",start_bar:s.start/secondsPerBar,duration_bars:s.duration/secondsPerBar,clip_start_offset_bars:0.0,source_data:{// KHÔNG fallback s.id: section item thiếu sectionId (insert thiếu -// field) → fallback = chính id item → TỰ TRỎ → block lồng + mất -// nội dung. Chỉ dùng sectionId; undefined → deserialize block rỗng. -referenced_section_id:s.sectionId}});});}return{id:t.id,name:t.name,type:trackType,color:t.color||null,volume_db:t.volumeDb||0.0,pan:t.pan||0.0,mute:t.muted||false,solo:t.solo||false,mastering_bypass:t.audioBypass||false,audio_bypass:t.audioBypass||false,midi_bypass:t.midiBypass||false,fx_active:t.fxActive!==false,instrument_id:t.instrumentId||null,instrument_program:t.instrumentProgram!==undefined?t.instrumentProgram:null,instrument_name:t.instrumentName||null,instrument_source:t.instrument_source||(t.synth_engine?t.synth_engine.type:null),soundfont_id:t.soundfont_id||(t.synth_engine?t.synth_engine.soundfont_id:null),soundfont_bank:t.soundfont_bank!==undefined?t.soundfont_bank:t.synth_engine?t.synth_engine.soundfont_bank:null,soundfont_program:t.soundfont_program!==undefined?t.soundfont_program:t.synth_engine?t.synth_engine.soundfont_program:null,synth_engine:t.synth_engine||undefined,midi_channel:t.midiChannel!==undefined?t.midiChannel:null,fx_chain:(t.fxChain||[]).map(m=>typeof m==='string'?{type:m,active:true}:{type:m.type,active:m.active!==false}),vst_fx_chain:(t.vstFxChain||[]).map(s=>({type:s.type||'vst3',path:s.path||'',name:s.name||'',preset_b64:_capturedPresetByPath[s.path]||s.preset_b64||'',bypass:!!s.bypass})),vst_fx_bypass:!!t.vstFxBypass,vst_gui_open_path:t.id===_persistFxGuiTrackId&&_persistFxGuiOpen&&_persistFxGuiOpen.path?_persistFxGuiOpen.path:t.vstGuiOpenPath||null,vst_gui_open_name:t.id===_persistFxGuiTrackId&&_persistFxGuiOpen&&_persistFxGuiOpen.name?_persistFxGuiOpen.name:t.vstGuiOpenName||null,server_file_id:t.serverFileId||null,items:items};});};const deserializeTracksList=(schemaTracks,secondsPerBar,sectionStore,visitedSections,depth)=>{// Guard CHU TRÌNH: SECTION_ITEM trỏ về section đang được deserialize (hoặc +if(t.clips&&t.clips.length>0){t.clips.forEach(c=>{const durationSec=c.buffer?c.buffer.duration:4.0;const clipFileId=c.serverFileId||t.serverFileId;items.push({id:c.id,name:c.name||"Audio Clip",type:"AUDIO_ITEM",start_bar:c.startTime/secondsPerBar,duration_bars:durationSec/(c.speed||1.0)/secondsPerBar,clip_start_offset_bars:0.0,source_data:{audio_file_url:clipFileId?`/static/audio/uploads/${clipFileId}`:"",sample_rate:c.buffer?c.buffer.sampleRate:44100,channels:c.buffer?c.buffer.numberOfChannels:2,gain:1.0,server_file_id:clipFileId}});});}if(t.midiItems&&t.midiItems.length>0){t.midiItems.forEach(m=>{items.push({id:m.id,name:m.name||"MIDI Item",type:"MIDI_ITEM",start_bar:m.startTime/secondsPerBar,duration_bars:m.duration?m.duration/secondsPerBar:4.0,clip_start_offset_bars:0.0,source_data:{total_buffer_bars:m.duration?m.duration/secondsPerBar:8.0,notes:(m.notes||[]).map(n=>({id:n.id||'note_'+Math.random().toString(36).substr(2,9),pitch:n.pitch||60,start_beat:n.start_beat||0.0,duration_beats:n.duration_beats||1.0,velocity:n.velocity||0.8,pan:n.pan||0.0}))}});});}if(t.sections&&t.sections.length>0){t.sections.forEach(s=>{// BỎ section item thiếu sectionId (insertSectionAtPlayhead tạo section +// chưa gắn vào tab → s.sectionId undefined). Nếu serialize thật thì +// referenced_section_id bị JSON.stringify bỏ → source_data = {} → +// schema oneOf fail → lưu project báo lỗi xác thực (bug user gặp). +// Item vô nghĩa (chưa có nội dung tab) — bỏ là đúng, không mất data. +if(!s.sectionId)return;items.push({id:s.id,name:s.name||"Section Item",type:"SECTION_ITEM",start_bar:s.start/secondsPerBar,duration_bars:s.duration/secondsPerBar,clip_start_offset_bars:0.0,source_data:{referenced_section_id:s.sectionId}});});}return{id:t.id,name:t.name,type:trackType,color:t.color||null,volume_db:t.volumeDb||0.0,pan:t.pan||0.0,mute:t.muted||false,solo:t.solo||false,mastering_bypass:t.audioBypass||false,audio_bypass:t.audioBypass||false,midi_bypass:t.midiBypass||false,fx_active:t.fxActive!==false,instrument_id:t.instrumentId||null,instrument_program:t.instrumentProgram!==undefined?t.instrumentProgram:null,instrument_name:t.instrumentName||null,instrument_source:t.instrument_source||(t.synth_engine?t.synth_engine.type:null),soundfont_id:t.soundfont_id||(t.synth_engine?t.synth_engine.soundfont_id:null),soundfont_bank:t.soundfont_bank!==undefined?t.soundfont_bank:t.synth_engine?t.synth_engine.soundfont_bank:null,soundfont_program:t.soundfont_program!==undefined?t.soundfont_program:t.synth_engine?t.synth_engine.soundfont_program:null,synth_engine:t.synth_engine||undefined,midi_channel:t.midiChannel!==undefined?t.midiChannel:null,fx_chain:(t.fxChain||[]).map(m=>typeof m==='string'?{type:m,active:true}:{type:m.type,active:m.active!==false}),vst_fx_chain:(t.vstFxChain||[]).map(s=>({type:s.type||'vst3',path:s.path||'',name:s.name||'',preset_b64:_capturedPresetByPath[s.path]||s.preset_b64||'',bypass:!!s.bypass})),vst_fx_bypass:!!t.vstFxBypass,vst_gui_open_path:t.id===_persistFxGuiTrackId&&_persistFxGuiOpen&&_persistFxGuiOpen.path?_persistFxGuiOpen.path:t.vstGuiOpenPath||null,vst_gui_open_name:t.id===_persistFxGuiTrackId&&_persistFxGuiOpen&&_persistFxGuiOpen.name?_persistFxGuiOpen.name:t.vstGuiOpenName||null,server_file_id:t.serverFileId||null,items:items};});};const deserializeTracksList=(schemaTracks,secondsPerBar,sectionStore,visitedSections,depth)=>{// Guard CHU TRÌNH: SECTION_ITEM trỏ về section đang được deserialize (hoặc // chu trình A→B→A) → recursion vô hạn → RangeError: Maximum call stack size // exceeded khi restore project. Visited-set chặn cycle; depth chặn lồng sâu. const visited=visitedSections||new Set();const curDepth=depth||0;if(curDepth>12)return[];return(schemaTracks||[]).map(t=>{const clips=[];const sections=[];const midiItems=[];(t.items||[]).forEach(item=>{if(item.type==="AUDIO_ITEM"){const src=item.source_data||{};const clipFileId=src.server_file_id||(src.audio_file_url?src.audio_file_url.split('/').pop():null)||null;clips.push({id:item.id,name:item.name,startTime:item.start_bar*secondsPerBar,speed:1.0,duration:item.duration_bars*secondsPerBar,serverFileId:clipFileId});}else if(item.type==="MIDI_ITEM"){const src=item.source_data||{};midiItems.push({id:item.id,name:item.name,parent_track_id:t.id,startTime:item.start_bar*secondsPerBar,duration:item.duration_bars*secondsPerBar,length_bars:item.duration_bars||4,notes:(src.notes||[]).map(n=>({id:n.id,pitch:n.pitch||60,start_beat:n.start_beat||0.0,duration_beats:n.duration_beats||1.0,velocity:n.velocity||0.8,pan:n.pan||0.0}))});}else if(item.type==="SECTION_ITEM"){const src=item.source_data||{};const secId=src.referenced_section_id;const secContainer=sectionStore?sectionStore[secId]:null;const isCycle=secContainer?visited.has(secId):false;sections.push({id:item.id,name:item.name,start:item.start_bar*secondsPerBar,duration:item.duration_bars*secondsPerBar,length_bars:item.duration_bars||4,sectionId:secId,tracks:secContainer&&!isCycle?deserializeTracksList(secContainer.tracks,secondsPerBar,sectionStore,new Set(visited).add(secId),curDepth+1):null});}});return{id:t.id,name:t.name,type:t.type==='MIDI'?'MIDI':t.type==='SECTION'?'SECTION':'audio',volumeDb:t.volume_db||0.0,pan:t.pan||0.0,muted:t.mute||false,solo:t.solo||false,masteringBypass:t.audio_bypass!==undefined?t.audio_bypass:t.mastering_bypass||false,audioBypass:t.audio_bypass!==undefined?t.audio_bypass:t.mastering_bypass||false,midiBypass:t.midi_bypass!==undefined?t.midi_bypass:t.mastering_bypass||false,fxActive:t.fx_active!==undefined?t.fx_active:true,color:t.color||(t.id==='1'?'#0f766e':'#1d4ed8'),startTime:t.start_time||0,height:t.height||140,markers:t.markers||[],serverFileId:t.server_file_id||t.items&&t.items.find(function(i){return i.type==='AUDIO_ITEM';})?.source_data?.server_file_id||t.items&&t.items.find(function(i){return i.type==='AUDIO_ITEM';})?.source_data?.audio_file_url?.split('/').pop()||null,channelInfo:t.channel_info||null,isArmed:t.is_armed||false,monitoringEnabled:t.monitoring_enabled!==false,inputSource:t.input_source?{deviceType:t.input_source.device_type||'NONE',deviceId:t.input_source.device_id||''}:{deviceType:'NONE',deviceId:''},midiChannel:t.midi_channel!=null?t.midi_channel:undefined,is_percussion:t.is_percussion||false,clips:clips,sections:sections,midiItems:midiItems,instrumentId:t.instrumentId!=null?t.instrumentId:t.instrument_id||null,instrumentProgram:t.instrumentProgram!==undefined&&t.instrumentProgram!==null?t.instrumentProgram:t.instrument_program!==null?t.instrument_program:undefined,instrumentName:t.instrument_name||null,instrument_source:t.instrument_source||null,soundfont_id:t.soundfont_id||null,soundfont_bank:t.soundfont_bank!==null?t.soundfont_bank:undefined,soundfont_program:t.soundfont_program!==null?t.soundfont_program:undefined,synth_engine:t.synth_engine||undefined,fxChain:(t.fx_chain||[]).map(m=>typeof m==='string'?{type:m,active:true}:{type:m.type||m,active:m.active!==false}),vstFxChain:(t.vst_fx_chain||[]).map(s=>({type:s.type||'vst3',path:s.path||'',name:s.name||'',preset_b64:s.preset_b64||'',bypass:!!s.bypass})),vstFxBypass:t.vst_fx_bypass!==undefined?!!t.vst_fx_bypass:false,vstGuiOpenPath:t.vst_gui_open_path||null,vstGuiOpenName:t.vst_gui_open_name||null};});};const serializeProjectToSchema=(projectId,name,bpmVal,tracksList,subTabsList,sessionTabsList,masteringSettings,masterVolume)=>{const secondsPerBar=60.0/parseFloat(bpmVal||120)*4;const mainTracks=serializeTracksList(tracksList,secondsPerBar);const sectionStore={};// Helper: compute length_bars from tracks content @@ -1905,20 +1907,26 @@ const generateSynthToTrack=(trackId,type)=>{const sampleRate=44100;const duratio const addNewTrack=()=>{const curTracks=activeTracks;const rearrangeNewId=(curTracks.length+1).toString();const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const selectColor=colors[curTracks.length%colors.length];// SECTION-TAB: audioclip item mặc định BYPASS mastering (raw) — nút A xám; // MAIN session: qua mastering (A sáng). User requirement 15:00. const isSectionCtx=activeTabRef.current&&activeTabRef.current.startsWith('session_');updateActiveTracks(prev=>[...prev,{id:rearrangeNewId,name:`Track ${rearrangeNewId}`,buffer:null,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,masteringBypass:false,audioBypass:isSectionCtx?true:false,midiBypass:false,fxActive:true,color:selectColor,markers:[],serverFileId:null,clips:[],sections:[],midiItems:[],isArmed:false,monitoringEnabled:true,inputSource:{deviceType:'NONE',deviceId:''}}]);showToast(`Đã thêm Track ${rearrangeNewId}.`,'info');setTimeout(()=>lucide.createIcons(),200);return rearrangeNewId;};// ── Update tracks in active context (main session or section tab) ── -const updateActiveTracksRef=useCallback(updater=>{const currentSessionTabs=sessionTabsRef.current;const currentActiveTab=activeTabRef.current;var st=currentSessionTabs.find(s=>s.id===currentActiveTab);if(st){setSessionTabs(prev=>prev.map(s=>s.id===currentActiveTab?{...s,tracks:updater(s.tracks),isDirty:true}:s));return;}var pr=subTabsRef.current?subTabsRef.current.find(s=>s.id===currentActiveTab&&s.type==='PIANO_ROLL'):null;if(pr&&pr.parent_tab_id&&pr.parent_tab_id.startsWith('session_')){var parentSt=currentSessionTabs.find(s=>s.id===pr.parent_tab_id);if(parentSt){setSessionTabs(prev=>prev.map(s=>s.id===pr.parent_tab_id?{...s,tracks:updater(s.tracks),isDirty:true}:s));return;}}setTracks(updater);},[]);const updateActiveTracks=updateActiveTracksRef;const toggleTrackArm=trackId=>{updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,isArmed:!t.isArmed};}));setTimeout(()=>lucide.createIcons(),50);};const toggleTrackMonitor=trackId=>{updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,monitoringEnabled:!t.monitoringEnabled};}));setTimeout(()=>lucide.createIcons(),50);};const updateTrackInputSource=(trackId,type,deviceId)=>{updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,inputSource:{deviceType:type,deviceId}};}));};// ── MIDI Export: tạo file MIDI từ tất cả tracks ── +const updateActiveTracksRef=useCallback(updater=>{const currentSessionTabs=sessionTabsRef.current;const currentActiveTab=activeTabRef.current;var st=currentSessionTabs.find(s=>s.id===currentActiveTab);if(st){setSessionTabs(prev=>prev.map(s=>s.id===currentActiveTab?{...s,tracks:updater(s.tracks),isDirty:true}:s));return;}var pr=subTabsRef.current?subTabsRef.current.find(s=>s.id===currentActiveTab&&s.type==='PIANO_ROLL'):null;if(pr&&pr.parent_tab_id&&pr.parent_tab_id.startsWith('session_')){var parentSt=currentSessionTabs.find(s=>s.id===pr.parent_tab_id);if(parentSt){setSessionTabs(prev=>prev.map(s=>s.id===pr.parent_tab_id?{...s,tracks:updater(s.tracks),isDirty:true}:s));return;}}setTracks(updater);},[]);const updateActiveTracks=updateActiveTracksRef;const toggleTrackArm=trackId=>{updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,isArmed:!t.isArmed};}));setTimeout(()=>lucide.createIcons(),50);};const toggleTrackMonitor=trackId=>{updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,monitoringEnabled:!t.monitoringEnabled};}));setTimeout(()=>lucide.createIcons(),50);};const updateTrackInputSource=(trackId,type,deviceId)=>{updateActiveTracks(prev=>prev.map(t=>{if(t.id!==trackId)return t;return{...t,inputSource:{deviceType:type,deviceId}};}));};// ── Save-As qua NATIVE dialog (fix bug: export không hỏi nơi lưu) ── +// WebView2 chặn anchor-download → engine mở SaveFileDialog qua +// /api/v1/files/save-dialog (PowerShell hoặc Tauri bridge) → ghi bytes qua +// /api/v1/files/write. Trả path đã ghi; null nếu user hủy / không có dialog. +async function saveExportViaDialog(defaultName,blob){try{const dlg=await fetch(`${API_BASE_URL}/api/v1/files/save-dialog`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({default_name:defaultName,filter_name:defaultName.toLowerCase().endsWith('.mid')?'MIDI':'Audio',filter_ext:defaultName.split('.').pop()||'bin'})});if(!dlg.ok)return null;const d=await dlg.json();if(!d.path)return null;const fd=new FormData();fd.append('path',d.path);fd.append('file',new File([blob],defaultName,{type:blob.type||'application/octet-stream'}));const wr=await fetch(`${API_BASE_URL}/api/v1/files/write`,{method:'POST',body:fd});if(!wr.ok)return null;const w=await wr.json();return w.path;}catch(e){return null;}}// ── MIDI Export: tạo file MIDI từ tất cả tracks ── // ── Server-side MIDI export (BIG_PHASE_3 Phase 1) ── // Blob download bị WebView2 chặn sau khi chuyển standalone → POST // /api/v1/export/midi (server sinh file .mid) + GET download (network // download, Content-Disposition attachment — pattern đã chạy ổn định). // velocity gửi dạng 0..1 — backend scale sang 1..127 (sửa bug *100 cũ). -function serverDownloadMidi(payload,filename){return fetch(`${API_AUDIO}/export/midi`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(async res=>{if(!res.ok){const e=await res.json().catch(()=>({}));throw new Error(e.detail||'HTTP '+res.status);}return res.json();}).then(data=>{const a=document.createElement('a');a.href=`${API_BASE_URL}${data.download_url}`;a.download=filename;document.body.appendChild(a);a.click();document.body.removeChild(a);return data;});}// ── Export audio: tải qua server (network download, WebView2 không chặn) ── +function serverDownloadMidi(payload,filename){return fetch(`${API_AUDIO}/export/midi`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(async res=>{if(!res.ok){const e=await res.json().catch(()=>({}));throw new Error(e.detail||'HTTP '+res.status);}return res.json();}).then(async data=>{// NATIVE Save-As dialog (WebView2 chặn anchor download) → ghi file thật. +try{const blobResp=await fetch(`${API_BASE_URL}${data.download_url}`);if(blobResp.ok){const blob=await blobResp.blob();const savedPath=await saveExportViaDialog(filename,blob);if(savedPath){return{...data,saved_path:savedPath};}}}catch(e){/* fallback anchor dưới */}const a=document.createElement('a');a.href=`${API_BASE_URL}${data.download_url}`;a.download=filename;document.body.appendChild(a);a.click();document.body.removeChild(a);return data;});}// ── Export audio: tải qua server (network download, WebView2 không chặn) ── // Blob download bị WebView2 chặn sau khi chuyển standalone → upload file // tạm lên engine local (POST /api/v1/audio/upload) → tải về qua GET // /api/v1/audio/download/{file_id} (Content-Disposition attachment). // Nếu upload thất bại (server offline) → fallback blob download cũ. -async function downloadBlobViaServer(blob,filename){try{const fd=new FormData();fd.append('file',new File([blob],filename,{type:blob.type||'audio/wav'}));const up=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:fd});if(!up.ok)throw new Error('HTTP '+up.status);const data=await up.json();const a=document.createElement('a');a.href=`${API_BASE_URL}/api/v1/audio/download/${data.file_id}`;a.download=filename;document.body.appendChild(a);a.click();document.body.removeChild(a);return data;}catch(err){// fallback: blob download (một số môi trường vẫn cho phép) +async function downloadBlobViaServer(blob,filename){// NATIVE Save-As dialog trước (WebView2 chặn anchor download) → ghi file thật. +try{const savedPath=await saveExportViaDialog(filename,blob);if(savedPath)return{saved_path:savedPath};}catch(e){/* fallback upload+anchor dưới */}try{const fd=new FormData();fd.append('file',new File([blob],filename,{type:blob.type||'audio/wav'}));const up=await fetch(`${API_AUDIO}/upload`,{method:'POST',body:fd});if(!up.ok)throw new Error('HTTP '+up.status);const data=await up.json();const a=document.createElement('a');a.href=`${API_BASE_URL}/api/v1/audio/download/${data.file_id}`;a.download=filename;document.body.appendChild(a);a.click();document.body.removeChild(a);return data;}catch(err){// fallback: blob download (một số môi trường vẫn cho phép) const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=filename;document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(url);throw err;}}const triggerMidiExport=async()=>{const bpmNum=parseFloat(bpm)||120;const midiTracks=[];tracks.forEach(track=>{const notes=[];(track.midiItems||[]).forEach(item=>{(item.notes||[]).forEach(note=>{notes.push({pitch:note.pitch||60,start_beat:note.startBeat||note.start_beat||0,duration_beats:note.durationBeats||note.duration_beats||1,velocity:note.velocity!=null?note.velocity:0.8});});});// bỏ track không có nốt MIDI (trước đây chèn "rest note" pitch 0) -if(notes.length)midiTracks.push({name:track.name||'Track',notes});});if(!midiTracks.length){showToast("Không có dữ liệu MIDI nào để xuất.","warning");return;}try{await serverDownloadMidi({bpm:bpmNum,ppq:480,tracks:midiTracks},(projectName||'Project')+'.mid');showToast(`Đã xuất file MIDI với ${midiTracks.length} tracks!`,"success");}catch(err){showToast('Export MIDI thất bại: '+(err&&err.message?err.message:String(err)),'error');}};// ── Insert Track Below Selected ── +if(notes.length)midiTracks.push({name:track.name||'Track',notes});});if(!midiTracks.length){showToast("Không có dữ liệu MIDI nào để xuất.","warning");return;}try{const r=await serverDownloadMidi({bpm:bpmNum,ppq:480,tracks:midiTracks},(projectName||'Project')+'.mid');if(r&&r.saved_path){showToast(`Đã xuất file MIDI (${midiTracks.length} tracks) → ${r.saved_path}`,"success");}else{showToast(`Đã xuất file MIDI với ${midiTracks.length} tracks!`,"success");}}catch(err){showToast('Export MIDI thất bại: '+(err&&err.message?err.message:String(err)),'error');}};// ── Insert Track Below Selected ── const insertTrackBelow=()=>{const curTracks=activeTracks;const rearrangeNewId=`t${Date.now()}`;const colors=['#0f766e','#1d4ed8','#701a75','#a21caf','#b45309'];const selectColor=colors[curTracks.length%colors.length];const newTrack={id:rearrangeNewId,name:`Track ${rearrangeNewId}`,buffer:null,startTime:0,volumeDb:0,pan:0,muted:false,solo:false,masteringBypass:false,audioBypass:false,midiBypass:false,fxActive:true,color:selectColor,markers:[],serverFileId:null,clips:[],sections:[],midiItems:[]};updateActiveTracks(prev=>{const idx=prev.findIndex(t=>t.id===selectedTrackId);if(idx===-1)return[...prev,newTrack];const copy=[...prev];copy.splice(idx+1,0,newTrack);return copy;});setSelectedTrackId(rearrangeNewId);showToast(`Đã thêm Track ${rearrangeNewId}.`,'info');setTimeout(()=>lucide.createIcons(),200);};// ── Insert Section at Playhead ── const insertSectionAtPlayhead=()=>{const curTracks=activeTracks;const track=curTracks.find(t=>t.id===selectedTrackId);if(!track){showToast('Chọn track trước','warning');return;}const secondsPerBar=60.0/(parseInt(bpm)||120)*4;const section={id:`sec_${Date.now()}`,name:'Section',start:currentTime,duration:4*secondsPerBar,length_bars:4,color:track.color||'#06b6d4'};createSectionWithUndo(selectedTrackId,section);updateActiveTracks(prev=>prev.map(t=>t.id===selectedTrackId?{...t,sections:[...(t.sections||[]),section]}:t));showToast(`Đã thêm Section tại ${currentTime.toFixed(2)}s`,'success');};// ── Insert MIDI Item at Playhead ── const insertMidiItemAtPlayhead=()=>{const curTracks=activeTracks;const track=curTracks.find(t=>t.id===selectedTrackId);if(!track){showToast('Chọn track trước','warning');return;}const secondsPerBar=60.0/(parseInt(bpm)||120)*4;const midiItem={id:`midi_${Date.now()}`,name:'MIDI Item',startTime:currentTime,duration:4*secondsPerBar,length_bars:4,notes:[],color:'#a78bfa'};createMidiWithUndo(selectedTrackId,midiItem);updateActiveTracks(prev=>prev.map(t=>t.id===selectedTrackId?{...t,midiItems:[...(t.midiItems||[]),midiItem]}:t));showToast(`Đã thêm MIDI item tại ${currentTime.toFixed(2)}s`,'success');};// ── Insert Sound Clip at Cursor ── @@ -1938,7 +1946,7 @@ const allOnServer=exportTracks.every(t=>serverFileIdMap[t.id]);// Loop đã cắ if(!exportSettings.loop&&allOnServer&&serverStatus==='connected'){// Use server-side export setIsExporting(true);showToast("Đang gửi yêu cầu xuất âm thanh đến máy chủ...","info");try{const sessionId=`session_${Date.now()}`;const tracksMeta=exportTracks.map(t=>({track_id:t.id,file_id:serverFileIdMap[t.id],volume_db:t.volumeDb,muted:false,clips:[{clip_id:`clip_${t.id}`,start_time_seconds:t.startTime||0,end_time_seconds:(t.startTime||0)+t.buffer.duration,loop_count:1,apply_zero_crossing:true,fade_in_ms:0,fade_out_ms:0}]}));const resp=await fetch(`${API_MULTITRACK}/mix`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:sessionId,export_settings:{sample_rate:parseInt(exportSettings.sampleRate),bit_depth:parseInt(exportSettings.bitDepth),format:exportSettings.format,channels:exportSettings.channels},tracks:tracksMeta})});if(!resp.ok)throw new Error(`Server export failed: ${resp.status}`);const data=await resp.json();showToast("Đang xử lý trên máy chủ...","info");// Poll for result const result=await pollTaskResult(data.task_id,20);if(result.success){// Get the uploaded file from server -const fileId=result.output_file_id||result.output_path?.split('/').pop();if(fileId){const downloadUrl=`${API_AUDIO}/download/${fileId}`;showToast("Xuất bản âm thanh từ máy chủ hoàn tất!","success","Tải về",()=>{const a=document.createElement('a');a.href=downloadUrl;a.download=fileId;a.click();});}}else{throw new Error(result.error||'Server processing failed');}}catch(err){showToast("Lỗi xuất máy chủ: "+err.message+". Chuyển sang xuất client.","warning");// Fall back to client-side export +const fileId=result.output_file_id||result.output_path?.split('/').pop();if(fileId){const downloadUrl=`${API_AUDIO}/download/${fileId}`;showToast("Xuất bản âm thanh từ máy chủ hoàn tất!","success","Tải về",async()=>{try{const br=await fetch(downloadUrl);if(!br.ok)throw new Error('HTTP '+br.status);const b=await br.blob();const sp=await saveExportViaDialog((projectName||'Project')+'.wav',b);if(sp)showToast(`Đã lưu → ${sp}`,"success");}catch(e){const a=document.createElement('a');a.href=downloadUrl;a.download=fileId;a.click();}});}}else{throw new Error(result.error||'Server processing failed');}}catch(err){showToast("Lỗi xuất máy chủ: "+err.message+". Chuyển sang xuất client.","warning");// Fall back to client-side export clientSideExport(exportTracks);}finally{setIsExporting(false);}}else{// Client-side export (existing working code) clientSideExport(exportTracks,{loopStart,loopDur});}};// ── Realtime Bounce Export (đầy đủ MIDI + FX Rack + Mastering) ── // MIDI/soundfont chạy qua FluidSynth (ScriptProcessor) — không render được @@ -1978,7 +1986,7 @@ const savedMasterBus=masterBus;const offlineNodes={};try{window.currentMastering // đúng độ lớn nghe được (flow: ... → Mastering FX Chain → Main out). // Trước đây offline masterBus.output luôn = 1.0 → export sai volume. try{if(masterBus&&masterBus.output){const mv=typeof masterVolume==='number'&&isFinite(masterVolume)?masterVolume:0;masterBus.output.gain.setValueAtTime(mv<=-50?0:Math.pow(10,mv/20),0);}}catch(e){console.warn('offline main-out volume error:',e);}activeTracks.forEach(t=>{const node=buildOfflineTrackNode(t,offlineCtx,offlineNodes);if(!node)return;const clips=t.clips&&t.clips.length>0?t.clips:t.buffer?[{id:'default',buffer:t.buffer,startTime:t.startTime||0,name:t.name,speed:t.speed||1.0}]:[];clips.forEach(clip=>{if(!clip.buffer)return;const source=offlineCtx.createBufferSource();source.buffer=clip.buffer;source.playbackRate.value=clip.speed||1.0;source.connect(node.gainNode);const clipStart=clip.startTime||0;source.start(clipStart);});// MIDI preview cache → schedule through the SF chain (track FX + mastering) -if(node.sfEntry){const midiBuf=midiCacheToBuffer(t.id);if(midiBuf){let mSrcBuf=midiBuf;if(loopInfo&&loopInfo.loopDur){const ln=Math.min(midiBuf.length,Math.max(1,Math.ceil(loopInfo.loopDur*midiBuf.sampleRate)));mSrcBuf=offlineCtx.createBuffer(midiBuf.numberOfChannels,ln,midiBuf.sampleRate);for(let mc=0;mc{for(let i=0;i>8&0xFF);view.setUint8(offset+2,val24>>16&0xFF);}else if(isFloatWav){view.setFloat32(offset,sample,true);}offset+=bytesPerSample;}}const blob=new Blob([view],{type:'audio/wav'});const wavName=`sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`;if((exportSettings.format==='mp3'||exportSettings.format==='ogg')&&serverStatus==='connected'){setIsExporting(true);showToast("Đang gửi yêu cầu nén định dạng lên máy chủ...","info");try{const file=new File([blob],`session_export.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 to transcode server failed");const uploadData=await uploadResp.json();const fileId=uploadData.file_id;const exportResp=await fetch(`${API_AUDIO}/export`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:fileId,format:exportSettings.format,sample_rate:targetRate,bit_depth:bitDepth})});if(!exportResp.ok)throw new Error("Transcode request failed");const exportData=await exportResp.json();const result=await pollTaskResult(exportData.task_id,20);if(result.success){const outId=result.output_file_id;const downloadUrl=`${API_AUDIO}/download/${outId}`;showToast(`Xuất bản âm thanh định dạng ${exportSettings.format.toUpperCase()} thành công!`,"success","Tải về",()=>{const a=document.createElement('a');a.href=downloadUrl;a.download=`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`;a.click();});}else{throw new Error(result.error||'Server encoding failed');}}catch(transcodeErr){showToast(`Lỗi chuyển đổi: ${transcodeErr.message}.`,"warning","Tải WAV thay thế",()=>{downloadBlobViaServer(blob,wavName).catch(()=>{});});}}else{if(exportSettings.format!=='wav'){showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.","warning");}await downloadBlobViaServer(blob,wavName).catch(()=>{});showToast("Xuất bản âm thanh hoàn tất!","success");}}finally{// Restore the live master bus + live mastering state after offline render. +if(node.sfEntry){const midiBuf=midiCacheToBuffer(t.id);if(midiBuf){let mSrcBuf=midiBuf;if(loopInfo&&loopInfo.loopDur){const ln=Math.min(midiBuf.length,Math.max(1,Math.ceil(loopInfo.loopDur*midiBuf.sampleRate)));mSrcBuf=offlineCtx.createBuffer(midiBuf.numberOfChannels,ln,midiBuf.sampleRate);for(let mc=0;mc{for(let i=0;i>8&0xFF);view.setUint8(offset+2,val24>>16&0xFF);}else if(isFloatWav){view.setFloat32(offset,sample,true);}offset+=bytesPerSample;}}const blob=new Blob([view],{type:'audio/wav'});const wavName=`sonicforge-session-${targetRate}hz-${bitDepth}bit.wav`;if((exportSettings.format==='mp3'||exportSettings.format==='ogg')&&serverStatus==='connected'){setIsExporting(true);showToast("Đang gửi yêu cầu nén định dạng lên máy chủ...","info");try{const file=new File([blob],`session_export.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 to transcode server failed");const uploadData=await uploadResp.json();const fileId=uploadData.file_id;const exportResp=await fetch(`${API_AUDIO}/export`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:fileId,format:exportSettings.format,sample_rate:targetRate,bit_depth:bitDepth})});if(!exportResp.ok)throw new Error("Transcode request failed");const exportData=await exportResp.json();const result=await pollTaskResult(exportData.task_id,20);if(result.success){const outId=result.output_file_id;const downloadUrl=`${API_AUDIO}/download/${outId}`;showToast(`Xuất bản âm thanh định dạng ${exportSettings.format.toUpperCase()} thành công!`,"success","Tải về",async()=>{try{const br=await fetch(downloadUrl);if(!br.ok)throw new Error('HTTP '+br.status);const b=await br.blob();const sp=await saveExportViaDialog(`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`,b);if(sp)showToast(`Đã lưu → ${sp}`,"success");}catch(e){const a=document.createElement('a');a.href=downloadUrl;a.download=`sonicforge-session-${targetRate}hz-${bitDepth}bit.${exportSettings.format}`;a.click();}});}else{throw new Error(result.error||'Server encoding failed');}}catch(transcodeErr){showToast(`Lỗi chuyển đổi: ${transcodeErr.message}.`,"warning","Tải WAV thay thế",()=>{downloadBlobViaServer(blob,wavName).catch(()=>{});});}}else{if(exportSettings.format!=='wav'){showToast("Đang ngoại tuyến. Tải về định dạng WAV thay thế.","warning");}const dres=await downloadBlobViaServer(blob,wavName).catch(()=>null);if(dres&&dres.saved_path){showToast(`Xuất bản âm thanh hoàn tất → ${dres.saved_path}`,"success");}else{showToast("Xuất bản âm thanh hoàn tất!","success");}}}finally{// Restore the live master bus + live mastering state after offline render. masterBus=savedMasterBus;try{if(savedMasterBus)toggleMasteringOnMaster(!!(masteringSettings&&masteringSettings.masterConnected),!!(masteringSettings&&masteringSettings.isBypassed));}catch(e){}Object.keys(offlineNodes).forEach(k=>{try{if(offlineNodes[k].fxStopFn)offlineNodes[k].fxStopFn();}catch(e){}});}}catch(err){showToast("Lỗi xuất âm thanh: "+err.message,"error");}finally{setIsExporting(false);}};// ── AI Analysis (server-side with fallback) ── const triggerAIAnalysis=async()=>{setAnalysisState({status:'Connecting to AI Engine...',data:null,isRunning:true});// Check if we have a file on the server to analyze const activeTrack=tracks.find(t=>t.id===selectedTrackId);const serverFileId=serverFileIdMap[selectedTrackId];if(serverFileId&&serverStatus==='connected'){try{const resp=await fetch(`${API_AUDIO}/analyze-ai`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({file_id:serverFileId,api_base_url:aiConfig.baseUrl!==API_BASE_URL?aiConfig.baseUrl:null,model:aiConfig.model})});if(resp.ok){const data=await resp.json();showToast("Đang phân tích cấu trúc trên máy chủ...","info");try{const result=await pollTaskResult(data.task_id,15);if(result.bpm){if(selLeft!==null&&selRight!==null&&activeTrack&&activeTrack.buffer){const snapStart=findZeroCrossing(activeTrack.buffer,selLeft);const snapEnd=findZeroCrossing(activeTrack.buffer,selRight);if(selectionMode==='local'){setLocalSelectionStart(snapStart);setLocalSelectionEnd(snapEnd);}else{setSelectionStart(snapStart);setSelectionEnd(snapEnd);}}setAnalysisState({status:'Hoàn thành phân tích (Server)',data:result,isRunning:false});showToast(`AI Server: ${result.bpm} BPM | ${result.beats?.length||0} beats detected (Zero-Crossing Aligned)`,'success');return;}}catch(e){console.warn('Server AI poll failed, using fallback:',e);}}}catch(err){console.warn('Server AI analysis failed, using fallback:',err);}}// Fallback: client-side analysis with simulated BPM diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c746d3b..47d274c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -648,6 +648,40 @@ pub fn run() { waited_ms += 100; } } + // ── Native Save-As dialog bridge (export MIDI/WAV) ── + // Giong pick_dir: engine ghi ipc/save_file.request (chua ten + // mac dinh) → thread mo NATIVE SaveFileDialog → ghi ket qua + // vao ipc/save_file.response. Frontend lay path → POST bytes + // /api/v1/files/write → file thuc su duoc ghi vao noi chon. + let sreq = ipc_dir_for_thread.join("save_file.request"); + if sreq.exists() { + let default_name = std::fs::read_to_string(&sreq).unwrap_or_default(); + let _ = std::fs::remove_file(&sreq); + let sresp = ipc_dir_for_thread.join("save_file.response"); + let _ = std::fs::remove_file(&sresp); + let handle = app_handle.clone(); + let sresp_for_main = sresp.clone(); + let handle_for_closure = handle.clone(); + let _ = handle.run_on_main_thread(move || { + let saved: Option = handle_for_closure + .dialog() + .file() + .set_file_name(default_name.trim()) + .blocking_save_file(); + let val = saved + .map(|p| match p { + FilePath::Path(pb) => pb.to_string_lossy().to_string(), + FilePath::Url(u) => u.to_string(), + }) + .unwrap_or_default(); + let _ = std::fs::write(&sresp_for_main, val); + }); + let mut s_waited_ms = 0u32; + while !sresp.exists() && s_waited_ms < 300_000 { + std::thread::sleep(std::time::Duration::from_millis(100)); + s_waited_ms += 100; + } + } std::thread::sleep(std::time::Duration::from_millis(150)); } }); @@ -687,6 +721,16 @@ pub fn run() { // phu/an giu event loop song, Tauri khong tu exit; exit(0) // ket thuc dut diem (pump da dung, khong con respawn bridge). if window.label() == "main" { + // Xoa marker bridge khi app tat han — neu de lai, lan sau + // chay engine STANDALONE se nham tuong co bridge va cho + // response khong bao gio den (treo 120s truoc fallback). + let appdata = std::env::var("APPDATA").unwrap_or_else(|_| ".".into()); + let _ = std::fs::remove_file( + std::path::Path::new(&appdata) + .join("SonicForgeDAW") + .join("ipc") + .join("tauri_bridge_ready"), + ); // Orphan sweep: bat CA dau engine con song theo image name — // engine spawn thu cong (khong co SF_PARENT_PID), engine cua // lan chay truoc bi crash, hoac engine song sot khi app chet