diff --git a/app/api/v1/plugins.py b/app/api/v1/plugins.py index 8073c08..3443d4b 100644 --- a/app/api/v1/plugins.py +++ b/app/api/v1/plugins.py @@ -730,7 +730,8 @@ async def open_fx_gui(req: FxGuiRequest, current_user: dict = Depends(get_curren if req.embed: proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - close_fds=os.name != "nt") + close_fds=os.name != "nt", + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0) # Bridge prints SF_FXGUI_PORT= after binding its HTTP server. embed_url = None try: @@ -754,7 +755,8 @@ async def open_fx_gui(req: FxGuiRequest, current_user: dict = Depends(get_curren _register_fx_gui_process(proc) return {"success": True, "started": True, "already_running": False, "embed_url": embed_url, "cmd": cmd} - proc = subprocess.Popen(cmd, close_fds=os.name != "nt") + proc = subprocess.Popen(cmd, close_fds=os.name != "nt", + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0) proc._sf_plugin_path = plugin_path _register_fx_gui_process(proc) return {"success": True, "started": True, "already_running": False, "cmd": cmd} diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index a10b4a6..0d2e85c 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -11909,6 +11909,7 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor const boxRef = React.useRef(null); const deadRef = React.useRef(false); const screenRectRef = React.useRef(null); + const shownRef = React.useRef(false); const [dead, setDead] = React.useState(false); const post = (payload) => { try { fetch(url + '/input', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).catch(() => {}); } catch (e) {} }; React.useEffect(() => { @@ -11920,7 +11921,12 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor if (!deadRef.current) { deadRef.current = true; setDead(true); onDead && onDead(); } }); }, 2000); - return () => { clearInterval(tick); clearInterval(ping); }; + return () => { + clearInterval(tick); clearInterval(ping); + // Panel closed -> hide the native window (bridge stays alive so + // reopening is fast). The "Đóng GUI" button still POSTs /close (exit). + try { fetch(url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [url]); // Embed panel screen rect (physical px) for the bridge's anchored real-input @@ -11928,6 +11934,15 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor // not running under Tauri. Refreshed ~500ms so scroll/resize stays current. React.useEffect(() => { let alive = true; + const maybeShow = () => { + const r = screenRectRef.current; + if (!shownRef.current && r && r.w >= 4 && r.h >= 4) { + shownRef.current = true; + // Show the native window anchored over the panel (bridge stays + // alive; hidden again by /hide on unmount). + try { fetch(url + '/show', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rect: r }) }).catch(() => {}); } catch (e) {} + } + }; const refresh = () => { const box = boxRef.current; if (!box) return; @@ -11940,9 +11955,11 @@ const ExportModal = ({ open, onClose, exportSettings, setExportSettings, isExpor if (!alive || !boxRef.current) return; const rb = boxRef.current.getBoundingClientRect(); screenRectRef.current = { x: Math.round(pos.x + rb.left * sf), y: Math.round(pos.y + rb.top * sf), w: Math.round(rb.width * sf), h: Math.round(rb.height * sf) }; + maybeShow(); }).catch(() => {}); } else { screenRectRef.current = { x: Math.round((window.screenX || 0) + r.left), y: Math.round((window.screenY || 0) + r.top), w: Math.round(r.width), h: Math.round(r.height) }; + maybeShow(); } } catch (e) {} }; @@ -12085,10 +12102,14 @@ const FXRackModal = ({ track, onUpdateTrack, onClose }) => { const setVstChain = (next) => { if (onUpdateTrack) onUpdateTrack(track.id, { vstFxChain: next, vstFxBypass: vstBypass }); }; // Embedded VST GUI state (PLAN_MASTERBUS_VST_GUI_EMBED.md Phase 3) const [fxGuiOpen, setFxGuiOpen] = React.useState(null); // { path, url } | null + const fxGuiOpenRef = React.useRef(null); + fxGuiOpenRef.current = fxGuiOpen; const closeFxGui = () => { - if (fxGuiOpen) { try { fetch(fxGuiOpen.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} setFxGuiOpen(null); } + const g = fxGuiOpenRef.current; + if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} } + setFxGuiOpen(null); }; - React.useEffect(() => () => { if (fxGuiOpen) { try { fetch(fxGuiOpen.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} } }, []); + React.useEffect(() => () => { const g = fxGuiOpenRef.current; if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} } }, []); const toggleVst = (idx) => setVstChain(vstChain.map((s, i) => i === idx ? { ...s, bypass: !s.bypass } : s)); const removeVst = (idx) => { const rem = vstChain[idx]; if (rem && fxGuiOpen && fxGuiOpen.path === rem.path) closeFxGui(); if (idx === activeVstIdx) setActiveVstIdx(-1); setVstChain(vstChain.filter((_, i) => i !== idx)); }; const moveVst = (idx, dir) => { const j = idx + dir; if (j < 0 || j >= vstChain.length) return; const next = [...vstChain]; const mv = next.splice(idx, 1)[0]; next.splice(j, 0, mv); setVstChain(next); }; @@ -12621,12 +12642,23 @@ const MasteringModal = ({ isOpen, onClose, masteringSettings, setMasteringSettin // Embedded VST GUI state (PLAN_MASTERBUS_VST_GUI_EMBED.md Phase 2) const [masterGuiOpen, setMasterGuiOpen] = React.useState(null); // { path, url } | null const [activeMasterVstPath, setActiveMasterVstPath] = React.useState(null); + const masterGuiOpenRef = React.useRef(null); + masterGuiOpenRef.current = masterGuiOpen; const closeMasterGui = () => { - if (masterGuiOpen) { try { fetch(masterGuiOpen.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} setMasterGuiOpen(null); } + const g = masterGuiOpenRef.current; + if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} } + setMasterGuiOpen(null); setActiveMasterVstPath(null); }; - React.useEffect(() => () => { if (masterGuiOpen) { try { fetch(masterGuiOpen.url + '/close', { method: 'POST' }).catch(() => {}); } catch (e) {} } }, []); + React.useEffect(() => () => { const g = masterGuiOpenRef.current; if (g) { try { fetch(g.url + '/hide', { method: 'POST' }).catch(() => {}); } catch (e) {} } }, []); const setMasterVstChain = (next) => setOzState(prev => ({ ...prev, vstFxChain: next })); + // T2.4: push the master VST FX chain to the realtime bridge (live master + // mix). v1: master chain only — per-track vstChain stays offline (defer). + React.useEffect(() => { + if (!window.NativeBridgeService || !window.NativeBridgeService.setFxChain) return; + const chain = (ozState.vstFxChain || []).map(s => ({ path: s.path || '', bypass: !!s.bypass })); + window.NativeBridgeService.setFxChain(JSON.stringify(chain)); + }, [ozState.vstFxChain]); const toggleMasterVst = (idx) => setMasterVstChain(masterVstChain.map((s, i) => i === idx ? { ...s, bypass: !s.bypass } : s)); const removeMasterVst = (idx) => setMasterVstChain(masterVstChain.filter((_, i) => i !== idx)); const moveMasterVst = (idx, dir) => { const j = idx + dir; if (j < 0 || j >= masterVstChain.length) return; const next = [...masterVstChain]; const mv = next.splice(idx, 1)[0]; next.splice(j, 0, mv); setMasterVstChain(next); }; diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index 64812da..593f938 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -742,11 +742,15 @@ return{hx:Math.max(0,Math.min((canvasRef.current?canvasRef.current.clientWidth:4 // gửi pointer/key về bridge qua /input. Dùng chung MasteringModal + FXRackModal. // url = embed_url từ API fx-gui open (embed:true); onClose khi user đóng; // onDead khi bridge chết (ping fail) → parent trả graph + toast. -const VstGuiEmbed=({url,onClose,onDead})=>{const imgRef=React.useRef(null);const boxRef=React.useRef(null);const deadRef=React.useRef(false);const screenRectRef=React.useRef(null);const[dead,setDead]=React.useState(false);const post=payload=>{try{fetch(url+'/input',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).catch(()=>{});}catch(e){}};React.useEffect(()=>{deadRef.current=false;setDead(false);const tick=setInterval(()=>{const img=imgRef.current;if(img)img.src=url+'/frame?t='+Date.now();},100);const ping=setInterval(()=>{fetch(url+'/ping').then(r=>{if(!r.ok)throw new Error('bad');}).catch(()=>{if(!deadRef.current){deadRef.current=true;setDead(true);onDead&&onDead();}});},2000);return()=>{clearInterval(tick);clearInterval(ping);};// eslint-disable-next-line react-hooks/exhaustive-deps +const VstGuiEmbed=({url,onClose,onDead})=>{const imgRef=React.useRef(null);const boxRef=React.useRef(null);const deadRef=React.useRef(false);const screenRectRef=React.useRef(null);const shownRef=React.useRef(false);const[dead,setDead]=React.useState(false);const post=payload=>{try{fetch(url+'/input',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).catch(()=>{});}catch(e){}};React.useEffect(()=>{deadRef.current=false;setDead(false);const tick=setInterval(()=>{const img=imgRef.current;if(img)img.src=url+'/frame?t='+Date.now();},100);const ping=setInterval(()=>{fetch(url+'/ping').then(r=>{if(!r.ok)throw new Error('bad');}).catch(()=>{if(!deadRef.current){deadRef.current=true;setDead(true);onDead&&onDead();}});},2000);return()=>{clearInterval(tick);clearInterval(ping);// Panel closed -> hide the native window (bridge stays alive so +// reopening is fast). The "Đóng GUI" button still POSTs /close (exit). +try{fetch(url+'/hide',{method:'POST'}).catch(()=>{});}catch(e){}};// eslint-disable-next-line react-hooks/exhaustive-deps },[url]);// Embed panel screen rect (physical px) for the bridge's anchored real-input // dance: Tauri innerPosition + scaleFactor; window.screenX/Y fallback when // not running under Tauri. Refreshed ~500ms so scroll/resize stays current. -React.useEffect(()=>{let alive=true;const refresh=()=>{const box=boxRef.current;if(!box)return;const r=box.getBoundingClientRect();if(r.width<4||r.height<4)return;try{const tw=window.__TAURI__&&window.__TAURI__.window;if(tw&&tw.getCurrentWindow){Promise.all([tw.getCurrentWindow().innerPosition(),tw.getCurrentWindow().scaleFactor()]).then(([pos,sf])=>{if(!alive||!boxRef.current)return;const rb=boxRef.current.getBoundingClientRect();screenRectRef.current={x:Math.round(pos.x+rb.left*sf),y:Math.round(pos.y+rb.top*sf),w:Math.round(rb.width*sf),h:Math.round(rb.height*sf)};}).catch(()=>{});}else{screenRectRef.current={x:Math.round((window.screenX||0)+r.left),y:Math.round((window.screenY||0)+r.top),w:Math.round(r.width),h:Math.round(r.height)};}}catch(e){}};refresh();const iv=setInterval(refresh,500);return()=>{alive=false;clearInterval(iv);};// eslint-disable-next-line react-hooks/exhaustive-deps +React.useEffect(()=>{let alive=true;const maybeShow=()=>{const r=screenRectRef.current;if(!shownRef.current&&r&&r.w>=4&&r.h>=4){shownRef.current=true;// Show the native window anchored over the panel (bridge stays +// alive; hidden again by /hide on unmount). +try{fetch(url+'/show',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({rect:r})}).catch(()=>{});}catch(e){}}};const refresh=()=>{const box=boxRef.current;if(!box)return;const r=box.getBoundingClientRect();if(r.width<4||r.height<4)return;try{const tw=window.__TAURI__&&window.__TAURI__.window;if(tw&&tw.getCurrentWindow){Promise.all([tw.getCurrentWindow().innerPosition(),tw.getCurrentWindow().scaleFactor()]).then(([pos,sf])=>{if(!alive||!boxRef.current)return;const rb=boxRef.current.getBoundingClientRect();screenRectRef.current={x:Math.round(pos.x+rb.left*sf),y:Math.round(pos.y+rb.top*sf),w:Math.round(rb.width*sf),h:Math.round(rb.height*sf)};maybeShow();}).catch(()=>{});}else{screenRectRef.current={x:Math.round((window.screenX||0)+r.left),y:Math.round((window.screenY||0)+r.top),w:Math.round(r.width),h:Math.round(r.height)};maybeShow();}}catch(e){}};refresh();const iv=setInterval(refresh,500);return()=>{alive=false;clearInterval(iv);};// eslint-disable-next-line react-hooks/exhaustive-deps },[url]);const pt=e=>{const box=boxRef.current,img=imgRef.current;if(!box||!img||!img.naturalWidth||!img.naturalHeight)return null;const r=box.getBoundingClientRect();if(r.width===0||r.height===0)return null;return{x:Math.round((e.clientX-r.left)/r.width*img.naturalWidth),y:Math.round((e.clientY-r.top)/r.height*img.naturalHeight)};};const handle=e=>{const p=pt(e);if(!p)return;post({type:e.type==='mousedown'?'mousedown':e.type==='mouseup'?'mouseup':'mousemove',x:p.x,y:p.y,button:e.button,buttons:e.buttons,rect:screenRectRef.current||undefined});};const close=()=>{try{fetch(url+'/close',{method:'POST'}).catch(()=>{});}catch(e){}onClose&&onClose();};return/*#__PURE__*/React.createElement("div",{className:"relative w-full h-full flex flex-col"},/*#__PURE__*/React.createElement("div",{className:"flex items-center justify-between px-3 py-1.5 bg-slate-900/80 border border-slate-800 rounded-t-lg shrink-0"},/*#__PURE__*/React.createElement("span",{className:"text-[10px] text-violet-300 font-mono"},"VST GUI \u2014 nh\xFAng (frame-capture \xB7 ~10fps)"),/*#__PURE__*/React.createElement("div",{className:"flex items-center gap-2"},dead&&/*#__PURE__*/React.createElement("span",{className:"text-[9px] text-red-400 font-mono"},"bridge m\u1EA5t k\u1EBFt n\u1ED1i"),/*#__PURE__*/React.createElement("button",{onClick:close,className:"px-2 py-0.5 bg-red-900/60 hover:bg-red-800 text-red-100 text-[10px] font-semibold rounded transition",title:"\u0110\xF3ng GUI v\xE0 tr\u1EA3 v\u1EC1 graph"},"\u2715 \u0110\xF3ng GUI"))),/*#__PURE__*/React.createElement("div",{ref:boxRef,tabIndex:0,className:"flex-1 relative bg-slate-950 border border-slate-800 rounded-b-lg overflow-hidden outline-none",onMouseDown:handle,onMouseUp:handle,onMouseMove:handle,onMouseLeave:()=>post({type:'mouseup',x:-1,y:-1,button:0}),onWheel:e=>{const p=pt(e);if(p)post({type:'wheel',x:p.x,y:p.y,deltaY:e.deltaY,rect:screenRectRef.current||undefined});},onKeyDown:e=>{e.preventDefault();post({type:'keydown',keyCode:e.keyCode||e.which});},onKeyUp:e=>{e.preventDefault();post({type:'keyup',keyCode:e.keyCode||e.which});}},/*#__PURE__*/React.createElement("img",{ref:imgRef,alt:"VST GUI",className:"w-full h-full object-contain select-none pointer-events-none",draggable:false})));};const vstRow=(s,idx,toggleV,changeV,uploadV,moveV,removeV,fxl,chainLen,openGui,showCarla=true,activeIdx=-1,onSelect=null)=>/*#__PURE__*/React.createElement("div",{key:idx,onClick:()=>onSelect&&onSelect(s,idx),className:`flex items-center gap-2 bg-slate-900/60 border rounded-lg px-2 py-1.5 flex-wrap cursor-pointer transition-all ${activeIdx===idx?'border-teal-400 bg-slate-800/80':'border-slate-800 hover:border-slate-600'}`},/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();toggleV(idx);},className:`w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-bold shrink-0 ${!s.bypass?'bg-teal-500 text-slate-950':'bg-slate-700 text-slate-300'}`,title:s.bypass?'Bypass':'Active'},/*#__PURE__*/React.createElement("i",{"data-lucide":"power",className:"w-2.5 h-2.5"})),/*#__PURE__*/React.createElement("span",{className:"text-[10px] font-bold text-slate-300 font-mono w-6 shrink-0"},idx+1),/*#__PURE__*/React.createElement("select",{value:s.path||'',onClick:e=>e.stopPropagation(),onChange:e=>changeV(idx,e.target.value),className:"flex-1 min-w-[200px] bg-slate-950 border border-slate-700 text-teal-300 rounded px-2 py-1 text-[11px] outline-none focus:border-teal-500"},/*#__PURE__*/React.createElement("option",{value:""},"\u2014 Ch\u1ECDn VST FX \u2014"),(fxl||[]).map(f=>/*#__PURE__*/React.createElement("option",{key:f.path,value:f.path},f.name||f.id))),/*#__PURE__*/React.createElement("span",{className:`text-[9px] font-mono ${s.preset_b64?'text-emerald-400':'text-slate-600'}`},s.preset_b64?'✓ preset':'no preset'),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();uploadV(idx);},className:"px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 text-[10px] font-semibold rounded transition",title:"Upload .vstpreset (Save preset)"},"Preset"),openGui&&/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();openGui(s,idx);},disabled:!s.path,className:"px-2 py-1 bg-violet-800 hover:bg-violet-700 disabled:opacity-30 text-violet-100 text-[10px] font-semibold rounded transition",title:"M\u1EDF GUI c\u1EE7a VST (ch\u1EC9nh th\xF4ng s\u1ED1 tr\u1EF1c ti\u1EBFp)"},/*#__PURE__*/React.createElement("i",{"data-lucide":"monitor",className:"w-3 h-3"})," GUI"),showCarla&&/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();if(!s.path){window.showToast&&window.showToast('Chọn VST FX trước','warning');return;}window.SonicAPI.openInCarla(s.name||s.path,s.path).then(r=>{if(r&&r.success)window.showToast&&window.showToast('Đã mở Carla với '+(s.name||s.path)+' — chỉnh âm trong Carla rồi Save preset','success');else window.showToast&&window.showToast('Không mở được Carla','error');}).catch(err=>window.showToast&&window.showToast('Lỗi mở Carla: '+(err.message||err),'error'));},className:"px-2 py-1 bg-teal-800 hover:bg-teal-700 text-teal-100 text-[10px] font-semibold rounded transition",title:"M\u1EDF native GUI trong Carla"},"Carla"),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();moveV(idx,-1);},disabled:idx===0,className:"w-6 h-6 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs disabled:opacity-30",title:"L\xEAn"},/*#__PURE__*/React.createElement("i",{"data-lucide":"chevron-up",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();moveV(idx,1);},disabled:idx===chainLen-1,className:"w-6 h-6 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded text-xs disabled:opacity-30",title:"Xu\u1ED1ng"},/*#__PURE__*/React.createElement("i",{"data-lucide":"chevron-down",className:"w-3 h-3"})),/*#__PURE__*/React.createElement("button",{onClick:e=>{e.stopPropagation();removeV(idx);},className:"text-slate-600 hover:text-red-400 text-xs px-0.5",title:"X\xF3a slot"},/*#__PURE__*/React.createElement("i",{"data-lucide":"x",className:"w-3 h-3"})));// ── VST FX Plugin Picker (spec vst_fx_panel_integration.md §II) ────────── // Modal chọn VST FX theo category (heuristic từ backend list_fx.category). // Dùng chung FXRackModal + MasteringModal: [+] → picker → chèn VST card. @@ -755,7 +759,7 @@ const[scopeChannel,setScopeChannel]=React.useState('stereo');const[scopeMode,set const[fxList,setFxList]=React.useState([]);React.useEffect(()=>{if(window.SonicAPI&&window.SonicAPI.listFx){window.SonicAPI.listFx().then(d=>setFxList(d&&d.plugins||[])).catch(()=>setFxList([]));}},[track&&track.id]);const previewAudioRef=React.useRef(null);const[vstBypass,setVstBypass]=React.useState(!!(track&&track.vstFxBypass));const[activeVstIdx,setActiveVstIdx]=React.useState(-1);const[vstPickerOpen,setVstPickerOpen]=React.useState(false);if(!track)return null;const chain=track.fxChain||[];// ── VST FX chain (native_bridge --render-fx, áp dụng khi Export/Offline render) ── const vstChain=track.vstFxChain||[];const setVstChain=next=>{if(onUpdateTrack)onUpdateTrack(track.id,{vstFxChain:next,vstFxBypass:vstBypass});};// Embedded VST GUI state (PLAN_MASTERBUS_VST_GUI_EMBED.md Phase 3) const[fxGuiOpen,setFxGuiOpen]=React.useState(null);// { path, url } | null -const closeFxGui=()=>{if(fxGuiOpen){try{fetch(fxGuiOpen.url+'/close',{method:'POST'}).catch(()=>{});}catch(e){}setFxGuiOpen(null);}};React.useEffect(()=>()=>{if(fxGuiOpen){try{fetch(fxGuiOpen.url+'/close',{method:'POST'}).catch(()=>{});}catch(e){}}},[]);const toggleVst=idx=>setVstChain(vstChain.map((s,i)=>i===idx?{...s,bypass:!s.bypass}:s));const removeVst=idx=>{const rem=vstChain[idx];if(rem&&fxGuiOpen&&fxGuiOpen.path===rem.path)closeFxGui();if(idx===activeVstIdx)setActiveVstIdx(-1);setVstChain(vstChain.filter((_,i)=>i!==idx));};const moveVst=(idx,dir)=>{const j=idx+dir;if(j<0||j>=vstChain.length)return;const next=[...vstChain];const mv=next.splice(idx,1)[0];next.splice(j,0,mv);setVstChain(next);};const openVstGui=(s,idx)=>{if(!s||!s.path){window.showToast&&window.showToast('Chọn VST FX trước','warning');return;}if(fxGuiOpen)closeFxGui();if(idx!==undefined)setActiveVstIdx(idx);if(!window.SonicAPI||!window.SonicAPI.openFxGui){window.showToast&&window.showToast('Máy này không hỗ trợ mở GUI VST','warning');return;}window.SonicAPI.openFxGui({path:s.path,name:s.name||s.path,embed:true}).then(r=>{if(r&&r.embed_url)setFxGuiOpen({path:s.path,url:r.embed_url});else if(r&&r.already_running)window.showToast&&window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)','info');else window.showToast&&window.showToast('Không mở được GUI embed','error');}).catch(err=>window.showToast&&window.showToast('Lỗi mở GUI: '+(err.message||err),'error'));};const addVst=path=>{const fx=(fxList||[]).find(f=>f.path===path);if(!fx)return;const next=[...vstChain,{type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false}];setVstChain(next);setActiveVstIdx(next.length-1);};const changeVstPlugin=(idx,path)=>{const fx=(fxList||[]).find(f=>f.path===path);setVstChain(vstChain.map((s,i)=>i===idx?{...s,path,name:fx?fx.name||fx.id:s.name,preset_b64:''}:s));};const uploadVstPreset=idx=>{const inp=document.createElement('input');inp.type='file';inp.accept='.vstpreset,.fxp,.fxb,.dspreset';inp.onchange=()=>{const f=inp.files&&inp.files[0];if(!f)return;const reader=new FileReader();reader.onload=()=>{const b64=String(reader.result||'').split(',')[1]||'';setVstChain(vstChain.map((s,i)=>i===idx?{...s,preset_b64:b64}:s));window.showToast&&window.showToast('Đã gắn preset .vstpreset vào slot '+(idx+1),'success');};reader.readAsDataURL(f);};inp.click();};const previewFx=async()=>{try{if(previewAudioRef.current){previewAudioRef.current.pause();previewAudioRef.current=null;}if(!window.__renderProjectPreview){window.showToast&&window.showToast('Không có hàm render preview','warning');return;}window.showToast&&window.showToast('Đang render offline (bridge)...','info');const res=await window.__renderProjectPreview();const au=new Audio((window.API_BASE_URL||'')+res.url);previewAudioRef.current=au;au.onended=()=>{previewAudioRef.current=null;};au.play().then(()=>window.showToast&&window.showToast('Preview offline — âm thật = âm export (VST FX)','success')).catch(()=>{});}catch(e){/* toast ở __renderProjectPreview */}};const setChain=next=>{if(onUpdateTrack)onUpdateTrack(track.id,{fxChain:next});if(window.__rebuildTrackFxGraph)window.__rebuildTrackFxGraph(track.id);};const paramsOf=m=>({...(TRACK_FX_DEFAULTS[m.type]||{}),...(m.params||{})});const setParams=(idx,patch)=>{const next=chain.map((m,i)=>i===idx?{...m,params:{...paramsOf(m),...patch}}:m);setChain(next);};const toggleMod=idx=>{const next=chain.map((m,i)=>i===idx?{...m,active:!(m.active!==false)}:m);setChain(next);};const removeMod=idx=>setChain(chain.filter((_,i)=>i!==idx));const addMod=type=>{const def=TRACK_FX_DEFAULTS[type]||{};// Deep-clone default params so each chain entry owns its data (bands array +const fxGuiOpenRef=React.useRef(null);fxGuiOpenRef.current=fxGuiOpen;const closeFxGui=()=>{const g=fxGuiOpenRef.current;if(g){try{fetch(g.url+'/hide',{method:'POST'}).catch(()=>{});}catch(e){}}setFxGuiOpen(null);};React.useEffect(()=>()=>{const g=fxGuiOpenRef.current;if(g){try{fetch(g.url+'/hide',{method:'POST'}).catch(()=>{});}catch(e){}}},[]);const toggleVst=idx=>setVstChain(vstChain.map((s,i)=>i===idx?{...s,bypass:!s.bypass}:s));const removeVst=idx=>{const rem=vstChain[idx];if(rem&&fxGuiOpen&&fxGuiOpen.path===rem.path)closeFxGui();if(idx===activeVstIdx)setActiveVstIdx(-1);setVstChain(vstChain.filter((_,i)=>i!==idx));};const moveVst=(idx,dir)=>{const j=idx+dir;if(j<0||j>=vstChain.length)return;const next=[...vstChain];const mv=next.splice(idx,1)[0];next.splice(j,0,mv);setVstChain(next);};const openVstGui=(s,idx)=>{if(!s||!s.path){window.showToast&&window.showToast('Chọn VST FX trước','warning');return;}if(fxGuiOpen)closeFxGui();if(idx!==undefined)setActiveVstIdx(idx);if(!window.SonicAPI||!window.SonicAPI.openFxGui){window.showToast&&window.showToast('Máy này không hỗ trợ mở GUI VST','warning');return;}window.SonicAPI.openFxGui({path:s.path,name:s.name||s.path,embed:true}).then(r=>{if(r&&r.embed_url)setFxGuiOpen({path:s.path,url:r.embed_url});else if(r&&r.already_running)window.showToast&&window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)','info');else window.showToast&&window.showToast('Không mở được GUI embed','error');}).catch(err=>window.showToast&&window.showToast('Lỗi mở GUI: '+(err.message||err),'error'));};const addVst=path=>{const fx=(fxList||[]).find(f=>f.path===path);if(!fx)return;const next=[...vstChain,{type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false}];setVstChain(next);setActiveVstIdx(next.length-1);};const changeVstPlugin=(idx,path)=>{const fx=(fxList||[]).find(f=>f.path===path);setVstChain(vstChain.map((s,i)=>i===idx?{...s,path,name:fx?fx.name||fx.id:s.name,preset_b64:''}:s));};const uploadVstPreset=idx=>{const inp=document.createElement('input');inp.type='file';inp.accept='.vstpreset,.fxp,.fxb,.dspreset';inp.onchange=()=>{const f=inp.files&&inp.files[0];if(!f)return;const reader=new FileReader();reader.onload=()=>{const b64=String(reader.result||'').split(',')[1]||'';setVstChain(vstChain.map((s,i)=>i===idx?{...s,preset_b64:b64}:s));window.showToast&&window.showToast('Đã gắn preset .vstpreset vào slot '+(idx+1),'success');};reader.readAsDataURL(f);};inp.click();};const previewFx=async()=>{try{if(previewAudioRef.current){previewAudioRef.current.pause();previewAudioRef.current=null;}if(!window.__renderProjectPreview){window.showToast&&window.showToast('Không có hàm render preview','warning');return;}window.showToast&&window.showToast('Đang render offline (bridge)...','info');const res=await window.__renderProjectPreview();const au=new Audio((window.API_BASE_URL||'')+res.url);previewAudioRef.current=au;au.onended=()=>{previewAudioRef.current=null;};au.play().then(()=>window.showToast&&window.showToast('Preview offline — âm thật = âm export (VST FX)','success')).catch(()=>{});}catch(e){/* toast ở __renderProjectPreview */}};const setChain=next=>{if(onUpdateTrack)onUpdateTrack(track.id,{fxChain:next});if(window.__rebuildTrackFxGraph)window.__rebuildTrackFxGraph(track.id);};const paramsOf=m=>({...(TRACK_FX_DEFAULTS[m.type]||{}),...(m.params||{})});const setParams=(idx,patch)=>{const next=chain.map((m,i)=>i===idx?{...m,params:{...paramsOf(m),...patch}}:m);setChain(next);};const toggleMod=idx=>{const next=chain.map((m,i)=>i===idx?{...m,active:!(m.active!==false)}:m);setChain(next);};const removeMod=idx=>setChain(chain.filter((_,i)=>i!==idx));const addMod=type=>{const def=TRACK_FX_DEFAULTS[type]||{};// Deep-clone default params so each chain entry owns its data (bands array // especially — shared references would corrupt other modules' state). const params=Array.isArray(def.bands)?{...def,bands:JSON.parse(JSON.stringify(def.bands))}:{...def};setChain([...chain,{type,active:true,params}]);setActiveType(type);setAddModuleOpen(false);};const applyPreset=key=>{const preset=TRACK_EQ_PRESETS[key];if(!preset)return;const idx=chain.findIndex(m=>m.type==='eq'&&m.active!==false);if(idx>=0)setParams(idx,{g1:preset.g[0],g2:preset.g[1],g3:preset.g[2],g4:preset.g[3]});};const activeMod=chain.find(m=>m.type===activeType)||chain[chain.length-1]||null;const activeIdx=chain.findIndex(m=>m===activeMod);const ap=activeMod?paramsOf(activeMod):{};const slider=(label,val,min,max,step,color,onChange,fmt)=>/*#__PURE__*/React.createElement("div",{className:"flex flex-col gap-1"},/*#__PURE__*/React.createElement("div",{className:"flex justify-between text-[11px] font-mono"},/*#__PURE__*/React.createElement("span",{style:{color,fontWeight:700}},label),/*#__PURE__*/React.createElement("span",{className:"text-slate-300"},fmt?fmt(val):val)),/*#__PURE__*/React.createElement("input",{type:"range",min:min,max:max,step:step||0.1,value:val,onChange:e=>onChange(parseFloat(e.target.value)),className:"w-full h-1 cursor-pointer",style:{accentColor:color}}));// ── Wave Observer real-time scope render loop ── React.useEffect(()=>{const canvas=scopeCanvasRef.current;if(!canvas||!track)return;const st=scopeStateRef.current;let raf=null;const draw=()=>{raf=requestAnimationFrame(draw);const w=canvas.width,h=canvas.height;const ctx=canvas.getContext('2d');ctx.clearRect(0,0,w,h);// Grid @@ -774,7 +778,9 @@ ctx.fillStyle='#475569';ctx.font='9px monospace';ctx.fillText('+12 dB',4,midY-h/ const MasteringModal=({isOpen,onClose,masteringSettings,setMasteringSettings})=>{const ozState=masteringSettings;const setOzState=setMasteringSettings;const[isPlaying,setIsPlaying]=React.useState(false);const masterConnected=ozState.masterConnected;const setMasterConnected=val=>{setOzState(prev=>({...prev,masterConnected:typeof val==='function'?val(prev.masterConnected):val}));};// ── VST FX masterbus (native_bridge --render-fx, áp dụng khi Export/Offline render) ── const masterVstChain=ozState.vstFxChain||[];// Embedded VST GUI state (PLAN_MASTERBUS_VST_GUI_EMBED.md Phase 2) const[masterGuiOpen,setMasterGuiOpen]=React.useState(null);// { path, url } | null -const[activeMasterVstPath,setActiveMasterVstPath]=React.useState(null);const closeMasterGui=()=>{if(masterGuiOpen){try{fetch(masterGuiOpen.url+'/close',{method:'POST'}).catch(()=>{});}catch(e){}setMasterGuiOpen(null);}setActiveMasterVstPath(null);};React.useEffect(()=>()=>{if(masterGuiOpen){try{fetch(masterGuiOpen.url+'/close',{method:'POST'}).catch(()=>{});}catch(e){}}},[]);const setMasterVstChain=next=>setOzState(prev=>({...prev,vstFxChain:next}));const toggleMasterVst=idx=>setMasterVstChain(masterVstChain.map((s,i)=>i===idx?{...s,bypass:!s.bypass}:s));const removeMasterVst=idx=>setMasterVstChain(masterVstChain.filter((_,i)=>i!==idx));const moveMasterVst=(idx,dir)=>{const j=idx+dir;if(j<0||j>=masterVstChain.length)return;const next=[...masterVstChain];const mv=next.splice(idx,1)[0];next.splice(j,0,mv);setMasterVstChain(next);};const openMasterVstGui=s=>{if(!s||!s.path){window.showToast&&window.showToast('Chọn VST FX trước','warning');return;}if(masterGuiOpen)closeMasterGui();if(s.path)setActiveMasterVstPath(s.path);if(!window.SonicAPI||!window.SonicAPI.openFxGui){window.showToast&&window.showToast('Máy này không hỗ trợ mở GUI VST','warning');return;}window.SonicAPI.openFxGui({path:s.path,name:s.name||s.path,embed:true}).then(r=>{if(r&&r.embed_url)setMasterGuiOpen({path:s.path,url:r.embed_url});else if(r&&r.already_running)window.showToast&&window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)','info');else window.showToast&&window.showToast('Không mở được GUI embed','error');}).catch(err=>window.showToast&&window.showToast('Lỗi mở GUI: '+(err.message||err),'error'));};const addMasterVst=path=>{const fx=(masterFxList||[]).find(f=>f.path===path);if(!fx)return;setMasterVstChain([...masterVstChain,{type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false}]);openMasterVstGui({path:fx.path,name:fx.name||fx.id});};const changeMasterVstPlugin=(idx,path)=>{const fx=(masterFxList||[]).find(f=>f.path===path);setMasterVstChain(masterVstChain.map((s,i)=>i===idx?{...s,path,name:fx?fx.name||fx.id:s.name,preset_b64:''}:s));};const uploadMasterVstPreset=idx=>{const inp=document.createElement('input');inp.type='file';inp.accept='.vstpreset,.fxp,.fxb,.dspreset';inp.onchange=()=>{const f=inp.files&&inp.files[0];if(!f)return;const reader=new FileReader();reader.onload=()=>{const b64=String(reader.result||'').split(',')[1]||'';setMasterVstChain(masterVstChain.map((s,i)=>i===idx?{...s,preset_b64:b64}:s));window.showToast&&window.showToast('Đã gắn preset .vstpreset vào slot '+(idx+1),'success');};reader.readAsDataURL(f);};inp.click();};const previewMasterFx=async()=>{try{if(previewAudioRef.current){previewAudioRef.current.pause();previewAudioRef.current=null;}if(!window.__renderProjectPreview){window.showToast&&window.showToast('Không có hàm render preview','warning');return;}window.showToast&&window.showToast('Đang render offline (bridge)...','info');const res=await window.__renderProjectPreview();const au=new Audio((window.API_BASE_URL||'')+res.url);previewAudioRef.current=au;au.onended=()=>{previewAudioRef.current=null;};au.play().then(()=>window.showToast&&window.showToast('Preview offline — âm thật = âm export (VST FX)','success')).catch(()=>{});}catch(e){/* toast ở __renderProjectPreview */}};const audioRef=React.useRef({source:null});const eqCanvasRef=React.useRef(null);const imagerCanvasRef=React.useRef(null);const inMeterCanvasRef=React.useRef(null);const outMeterCanvasRef=React.useRef(null);const animFrameRef=React.useRef(null);const lufsAccRef=React.useRef([]);const knobsInitializedRef=React.useRef(false);// VST FX masterbus (native_bridge --render-fx) — list VST3 effect plugins + preview +const[activeMasterVstPath,setActiveMasterVstPath]=React.useState(null);const masterGuiOpenRef=React.useRef(null);masterGuiOpenRef.current=masterGuiOpen;const closeMasterGui=()=>{const g=masterGuiOpenRef.current;if(g){try{fetch(g.url+'/hide',{method:'POST'}).catch(()=>{});}catch(e){}}setMasterGuiOpen(null);setActiveMasterVstPath(null);};React.useEffect(()=>()=>{const g=masterGuiOpenRef.current;if(g){try{fetch(g.url+'/hide',{method:'POST'}).catch(()=>{});}catch(e){}}},[]);const setMasterVstChain=next=>setOzState(prev=>({...prev,vstFxChain:next}));// T2.4: push the master VST FX chain to the realtime bridge (live master +// mix). v1: master chain only — per-track vstChain stays offline (defer). +React.useEffect(()=>{if(!window.NativeBridgeService||!window.NativeBridgeService.setFxChain)return;const chain=(ozState.vstFxChain||[]).map(s=>({path:s.path||'',bypass:!!s.bypass}));window.NativeBridgeService.setFxChain(JSON.stringify(chain));},[ozState.vstFxChain]);const toggleMasterVst=idx=>setMasterVstChain(masterVstChain.map((s,i)=>i===idx?{...s,bypass:!s.bypass}:s));const removeMasterVst=idx=>setMasterVstChain(masterVstChain.filter((_,i)=>i!==idx));const moveMasterVst=(idx,dir)=>{const j=idx+dir;if(j<0||j>=masterVstChain.length)return;const next=[...masterVstChain];const mv=next.splice(idx,1)[0];next.splice(j,0,mv);setMasterVstChain(next);};const openMasterVstGui=s=>{if(!s||!s.path){window.showToast&&window.showToast('Chọn VST FX trước','warning');return;}if(masterGuiOpen)closeMasterGui();if(s.path)setActiveMasterVstPath(s.path);if(!window.SonicAPI||!window.SonicAPI.openFxGui){window.showToast&&window.showToast('Máy này không hỗ trợ mở GUI VST','warning');return;}window.SonicAPI.openFxGui({path:s.path,name:s.name||s.path,embed:true}).then(r=>{if(r&&r.embed_url)setMasterGuiOpen({path:s.path,url:r.embed_url});else if(r&&r.already_running)window.showToast&&window.showToast('GUI của plugin này đang mở sẵn (cửa sổ ngoài)','info');else window.showToast&&window.showToast('Không mở được GUI embed','error');}).catch(err=>window.showToast&&window.showToast('Lỗi mở GUI: '+(err.message||err),'error'));};const addMasterVst=path=>{const fx=(masterFxList||[]).find(f=>f.path===path);if(!fx)return;setMasterVstChain([...masterVstChain,{type:'vst3',path:fx.path,name:fx.name||fx.id,preset_b64:'',bypass:false}]);openMasterVstGui({path:fx.path,name:fx.name||fx.id});};const changeMasterVstPlugin=(idx,path)=>{const fx=(masterFxList||[]).find(f=>f.path===path);setMasterVstChain(masterVstChain.map((s,i)=>i===idx?{...s,path,name:fx?fx.name||fx.id:s.name,preset_b64:''}:s));};const uploadMasterVstPreset=idx=>{const inp=document.createElement('input');inp.type='file';inp.accept='.vstpreset,.fxp,.fxb,.dspreset';inp.onchange=()=>{const f=inp.files&&inp.files[0];if(!f)return;const reader=new FileReader();reader.onload=()=>{const b64=String(reader.result||'').split(',')[1]||'';setMasterVstChain(masterVstChain.map((s,i)=>i===idx?{...s,preset_b64:b64}:s));window.showToast&&window.showToast('Đã gắn preset .vstpreset vào slot '+(idx+1),'success');};reader.readAsDataURL(f);};inp.click();};const previewMasterFx=async()=>{try{if(previewAudioRef.current){previewAudioRef.current.pause();previewAudioRef.current=null;}if(!window.__renderProjectPreview){window.showToast&&window.showToast('Không có hàm render preview','warning');return;}window.showToast&&window.showToast('Đang render offline (bridge)...','info');const res=await window.__renderProjectPreview();const au=new Audio((window.API_BASE_URL||'')+res.url);previewAudioRef.current=au;au.onended=()=>{previewAudioRef.current=null;};au.play().then(()=>window.showToast&&window.showToast('Preview offline — âm thật = âm export (VST FX)','success')).catch(()=>{});}catch(e){/* toast ở __renderProjectPreview */}};const audioRef=React.useRef({source:null});const eqCanvasRef=React.useRef(null);const imagerCanvasRef=React.useRef(null);const inMeterCanvasRef=React.useRef(null);const outMeterCanvasRef=React.useRef(null);const animFrameRef=React.useRef(null);const lufsAccRef=React.useRef([]);const knobsInitializedRef=React.useRef(false);// VST FX masterbus (native_bridge --render-fx) — list VST3 effect plugins + preview const[masterFxList,setMasterFxList]=React.useState([]);React.useEffect(()=>{if(window.SonicAPI&&window.SonicAPI.listFx){window.SonicAPI.listFx().then(d=>setMasterFxList(d&&d.plugins||[])).catch(()=>setMasterFxList([]));}},[]);const previewAudioRef=React.useRef(null);const[addMasterVstSel,setAddMasterVstSel]=React.useState('');const[masterVstPickerOpen,setMasterVstPickerOpen]=React.useState(false);// Wave Observer Refs & States const woCanvasRef=React.useRef(null);const woLeftHistoryRef=React.useRef(new Float32Array(400).fill(0));const woRightHistoryRef=React.useRef(new Float32Array(400).fill(0));const woLeftMeterRef=React.useRef(null);const woRightMeterRef=React.useRef(null);const[woPaused,setWoPaused]=React.useState(false);const[woChannel,setWoChannel]=React.useState('stereo');const[woMode,setWoMode]=React.useState('waveform');const[woDuration,setWoDuration]=React.useState(2.0);const[woZoom,setWoZoom]=React.useState(0.0);const woPausedRef=React.useRef(woPaused);woPausedRef.current=woPaused;const woChannelRef=React.useRef(woChannel);woChannelRef.current=woChannel;const woModeRef=React.useRef(woMode);woModeRef.current=woMode;const woDurationRef=React.useRef(woDuration);woDurationRef.current=woDuration;const woZoomRef=React.useRef(woZoom);woZoomRef.current=woZoom;const ozStateRef=React.useRef(ozState);ozStateRef.current=ozState;function startAudioDemo(){getAudioContext();const ctx=audioCtx;if(ctx.state==='suspended')ctx.resume();stopAudioDemo();const sampleRate=ctx.sampleRate;const bufferSize=sampleRate*4;const buffer=ctx.createBuffer(2,bufferSize,sampleRate);const left=buffer.getChannelData(0);const right=buffer.getChannelData(1);for(let i=0;i{if(!isOpen)return;getAudioContext();function resizeAll(){const resizeCanvas=ref=>{const el=ref.current;if(el){el.width=el.clientWidth;el.height=el.clientHeight;}};resizeCanvas(eqCanvasRef);resizeCanvas(imagerCanvasRef);resizeCanvas(inMeterCanvasRef);resizeCanvas(outMeterCanvasRef);resizeCanvas(woCanvasRef);}resizeAll();window.addEventListener('resize',resizeAll);const fftData=new Uint8Array(1024);function getPeakLevel(analyser){if(!analyser)return 0;const bufferLength=analyser.fftSize;const dataArray=new Float32Array(bufferLength);analyser.getFloatTimeDomainData(dataArray);let maxVal=0;for(let i=0;imaxVal){maxVal=val;}}return maxVal;}function renderFrame(){animFrameRef.current=requestAnimationFrame(renderFrame);const s=ozStateRef.current;// EQ Spectrum const eqCanvas=eqCanvasRef.current;if(eqCanvas){const w=eqCanvas.width,h=eqCanvas.height;const eqCtx=eqCanvas.getContext('2d');eqCtx.clearRect(0,0,w,h);eqCtx.strokeStyle='rgba(51, 65, 85, 0.3)';eqCtx.lineWidth=1;eqCtx.font='9px JetBrains Mono';eqCtx.fillStyle='#475569';const freqs=[20,50,100,200,500,1000,2000,5000,10000,20000];freqs.forEach(f=>{const x=Math.log10(f/20)/Math.log10(20000/20)*w;eqCtx.beginPath();eqCtx.moveTo(x,0);eqCtx.lineTo(x,h);eqCtx.stroke();if(f>=1000)eqCtx.fillText(`${f/1000}k`,x+3,h-6);else eqCtx.fillText(`${f}`,x+3,h-6);});if(masterBus&&masterBus.outputAnalyser){masterBus.outputAnalyser.getByteFrequencyData(fftData);eqCtx.fillStyle='rgba(56, 189, 248, 0.15)';const barWidth=w/128;for(let i=0;i<128;i++){const val=fftData[i*4]/255;eqCtx.fillRect(i*barWidth,h-val*h,barWidth-1,val*h);}}eqCtx.strokeStyle='#38bdf8';eqCtx.lineWidth=2.5;eqCtx.beginPath();for(let x=0;x - + @@ -50,7 +50,7 @@ - +